@adia-ai/web-components 0.6.35 → 0.6.37

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.
Files changed (126) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/components/badge/badge.a2ui.json +10 -0
  3. package/components/badge/badge.css +70 -0
  4. package/components/badge/badge.yaml +20 -0
  5. package/components/blockquote/blockquote.a2ui.json +121 -0
  6. package/components/blockquote/blockquote.class.js +68 -0
  7. package/components/blockquote/blockquote.css +46 -0
  8. package/components/blockquote/blockquote.d.ts +31 -0
  9. package/components/blockquote/blockquote.js +17 -0
  10. package/components/blockquote/blockquote.yaml +124 -0
  11. package/components/button/button.css +11 -3
  12. package/components/calendar-picker/calendar-picker.a2ui.json +15 -0
  13. package/components/calendar-picker/calendar-picker.class.js +7 -1
  14. package/components/calendar-picker/calendar-picker.yaml +14 -0
  15. package/components/color-input/color-input.a2ui.json +2 -2
  16. package/components/color-input/color-input.class.js +9 -2
  17. package/components/color-input/color-input.yaml +2 -2
  18. package/components/combobox/combobox.class.js +4 -0
  19. package/components/combobox/combobox.css +12 -0
  20. package/components/context-menu/context-menu.a2ui.json +159 -0
  21. package/components/context-menu/context-menu.class.js +275 -0
  22. package/components/context-menu/context-menu.css +56 -0
  23. package/components/context-menu/context-menu.d.ts +70 -0
  24. package/components/context-menu/context-menu.js +17 -0
  25. package/components/context-menu/context-menu.yaml +136 -0
  26. package/components/date-range-picker/date-range-picker.a2ui.json +15 -0
  27. package/components/date-range-picker/date-range-picker.class.js +3 -1
  28. package/components/date-range-picker/date-range-picker.css +4 -1
  29. package/components/date-range-picker/date-range-picker.yaml +14 -0
  30. package/components/datetime-picker/datetime-picker.a2ui.json +15 -0
  31. package/components/datetime-picker/datetime-picker.class.js +3 -1
  32. package/components/datetime-picker/datetime-picker.css +7 -1
  33. package/components/datetime-picker/datetime-picker.d.ts +2 -0
  34. package/components/datetime-picker/datetime-picker.yaml +14 -0
  35. package/components/empty-state/empty-state.class.js +2 -0
  36. package/components/feed/feed.class.js +13 -5
  37. package/components/feed/feed.css +14 -0
  38. package/components/index.js +9 -0
  39. package/components/input/input.css +15 -1
  40. package/components/input/input.test.js +40 -0
  41. package/components/integration-card/integration-card.class.js +9 -0
  42. package/components/integration-card/integration-card.test.js +4 -3
  43. package/components/nav-group/nav-group.css +7 -1
  44. package/components/number-format/number-format.a2ui.json +180 -0
  45. package/components/number-format/number-format.class.js +96 -0
  46. package/components/number-format/number-format.css +18 -0
  47. package/components/number-format/number-format.d.ts +68 -0
  48. package/components/number-format/number-format.js +17 -0
  49. package/components/number-format/number-format.yaml +204 -0
  50. package/components/pagination/pagination.a2ui.json +19 -2
  51. package/components/pagination/pagination.class.js +90 -37
  52. package/components/pagination/pagination.css +32 -127
  53. package/components/pagination/pagination.d.ts +8 -2
  54. package/components/pagination/pagination.test.js +195 -0
  55. package/components/pagination/pagination.yaml +22 -1
  56. package/components/password-strength/password-strength.a2ui.json +152 -0
  57. package/components/password-strength/password-strength.class.js +157 -0
  58. package/components/password-strength/password-strength.css +80 -0
  59. package/components/password-strength/password-strength.d.ts +59 -0
  60. package/components/password-strength/password-strength.js +17 -0
  61. package/components/password-strength/password-strength.yaml +153 -0
  62. package/components/popover/popover.css +43 -23
  63. package/components/popover/popover.yaml +8 -4
  64. package/components/qr-code/QR-TEST.svg +4 -0
  65. package/components/qr-code/qr-code.a2ui.json +154 -0
  66. package/components/qr-code/qr-code.class.js +129 -0
  67. package/components/qr-code/qr-code.css +41 -0
  68. package/components/qr-code/qr-code.d.ts +83 -0
  69. package/components/qr-code/qr-code.js +17 -0
  70. package/components/qr-code/qr-code.yaml +203 -0
  71. package/components/qr-code/qr-encoder.js +633 -0
  72. package/components/relative-time/relative-time.a2ui.json +120 -0
  73. package/components/relative-time/relative-time.class.js +136 -0
  74. package/components/relative-time/relative-time.css +22 -0
  75. package/components/relative-time/relative-time.d.ts +51 -0
  76. package/components/relative-time/relative-time.js +17 -0
  77. package/components/relative-time/relative-time.yaml +133 -0
  78. package/components/search/search.class.js +2 -0
  79. package/components/segmented/segmented.class.js +5 -1
  80. package/components/select/select.class.js +4 -0
  81. package/components/skip-nav/skip-nav.a2ui.json +92 -0
  82. package/components/skip-nav/skip-nav.class.js +45 -0
  83. package/components/skip-nav/skip-nav.css +54 -0
  84. package/components/skip-nav/skip-nav.d.ts +27 -0
  85. package/components/skip-nav/skip-nav.js +12 -0
  86. package/components/skip-nav/skip-nav.yaml +68 -0
  87. package/components/slider/slider.a2ui.json +16 -1
  88. package/components/slider/slider.class.js +264 -122
  89. package/components/slider/slider.css +82 -2
  90. package/components/slider/slider.d.ts +19 -3
  91. package/components/slider/slider.test.js +55 -0
  92. package/components/slider/slider.yaml +28 -6
  93. package/components/table/table.class.js +29 -6
  94. package/components/table/table.css +31 -4
  95. package/components/table-toolbar/table-toolbar.class.js +4 -1
  96. package/components/tag/tag.a2ui.json +10 -0
  97. package/components/tag/tag.class.js +8 -1
  98. package/components/tag/tag.css +108 -20
  99. package/components/tag/tag.d.ts +14 -0
  100. package/components/tag/tag.test.js +99 -1
  101. package/components/tag/tag.yaml +20 -0
  102. package/components/tags-input/tags-input.class.js +10 -3
  103. package/components/tags-input/tags-input.css +12 -3
  104. package/components/textarea/textarea.css +10 -1
  105. package/components/toast/toast.class.js +12 -4
  106. package/components/toc/toc.a2ui.json +159 -0
  107. package/components/toc/toc.class.js +222 -0
  108. package/components/toc/toc.css +92 -0
  109. package/components/toc/toc.d.ts +61 -0
  110. package/components/toc/toc.js +17 -0
  111. package/components/toc/toc.yaml +180 -0
  112. package/components/toolbar/toolbar.class.js +3 -0
  113. package/components/visually-hidden/visually-hidden.a2ui.json +71 -0
  114. package/components/visually-hidden/visually-hidden.class.js +14 -0
  115. package/components/visually-hidden/visually-hidden.css +25 -0
  116. package/components/visually-hidden/visually-hidden.d.ts +26 -0
  117. package/components/visually-hidden/visually-hidden.js +12 -0
  118. package/components/visually-hidden/visually-hidden.yaml +54 -0
  119. package/core/anchor.js +19 -3
  120. package/core/provider.js +19 -2
  121. package/dist/web-components.min.css +1 -1
  122. package/dist/web-components.min.js +101 -89
  123. package/package.json +1 -1
  124. package/styles/colors/semantics.css +11 -2
  125. package/styles/components.css +9 -0
  126. package/styles/resets.css +10 -0
@@ -1,20 +1,36 @@
1
1
  /**
2
- * `<slider-ui>` — Single-handle slider for numeric input.
2
+ * `<slider-ui>` — Single- or two-thumb slider for numeric input.
3
+ *
4
+ * Default: single-thumb (`value`).
5
+ * Dual mode: set `[dual]` and use `[lower-value]` / `[upper-value]`; the fill
6
+ * spans between the thumbs and the `change` event detail becomes
7
+ * `{lower, upper}`.
3
8
  *
4
9
  * @see https://ui-kit.exe.xyz/site/components/slider
5
10
  */
6
11
 
7
12
  import { UIFormElement } from '../../core/form.js';
8
13
 
9
- export interface SliderChangeEventDetail {
14
+ export interface SliderChangeEventDetailSingle {
10
15
  value: number;
11
16
  }
17
+ export interface SliderChangeEventDetailDual {
18
+ lower: number;
19
+ upper: number;
20
+ }
21
+ export type SliderChangeEventDetail = SliderChangeEventDetailSingle | SliderChangeEventDetailDual;
12
22
  export type SliderChangeEvent = CustomEvent<SliderChangeEventDetail>;
13
23
  export type SliderInputEvent = CustomEvent<SliderChangeEventDetail>;
14
24
 
15
25
  export class UISlider extends UIFormElement {
16
- /** Numeric value — overrides UIFormElement.value (which is String). */
26
+ /** Numeric value — overrides UIFormElement.value (which is String). Single-thumb mode; ignored when [dual]. */
17
27
  value: number;
28
+ /** Two-thumb range slider mode. */
29
+ dual: boolean;
30
+ /** Lower thumb value (dual mode). */
31
+ lowerValue: number;
32
+ /** Upper thumb value (dual mode). */
33
+ upperValue: number;
18
34
  min: number;
19
35
  max: number;
20
36
  step: number;
@@ -144,4 +144,59 @@ describe('slider-ui', () => {
144
144
 
145
145
  expect(s.querySelector('[slot="suffix"]').textContent).toBe('rem');
146
146
  });
147
+
148
+ // ─── Dual-thumb mode ────────────────────────────────────────────────
149
+ // v1.9.x: dual-thumb range slider lives on slider-ui[dual] + [lower-value]/[upper-value].
150
+ // Fill spans BETWEEN the two thumb centers; thumbs are positioned via
151
+ // --slider-pct-lower / --slider-pct-upper CSS variables.
152
+ const pctLower = (s) => s.style.getPropertyValue('--slider-pct-lower').trim();
153
+ const pctUpper = (s) => s.style.getPropertyValue('--slider-pct-upper').trim();
154
+
155
+ it('dual mode: writes --slider-pct-lower and --slider-pct-upper from lower-value / upper-value', async () => {
156
+ const s = mount('<slider-ui dual lower-value="20" upper-value="80" min="0" max="100"></slider-ui>');
157
+ await tick();
158
+ expect(pctLower(s)).toBe('0.2');
159
+ expect(pctUpper(s)).toBe('0.8');
160
+ });
161
+
162
+ it('dual mode: stamps two thumb elements with data-thumb="lower" and data-thumb="upper"', async () => {
163
+ const s = mount('<slider-ui dual lower-value="20" upper-value="80" min="0" max="100"></slider-ui>');
164
+ await tick();
165
+ const lowerThumb = s.querySelector('[slot="thumb"][data-thumb="lower"]');
166
+ const upperThumb = s.querySelector('[slot="thumb"][data-thumb="upper"]');
167
+ expect(lowerThumb).not.toBeNull();
168
+ expect(upperThumb).not.toBeNull();
169
+ });
170
+
171
+ it('dual mode: change event detail carries {lower, upper}', async () => {
172
+ const s = mount('<slider-ui dual lower-value="20" upper-value="80" min="0" max="100" step="1"></slider-ui>');
173
+ await tick();
174
+ let captured = null;
175
+ s.addEventListener('change', (e) => { captured = e; });
176
+ // Focus the lower thumb explicitly so the keyboard handler targets it
177
+ const lowerThumb = s.querySelector('[slot="thumb"][data-thumb="lower"]');
178
+ lowerThumb.focus();
179
+ lowerThumb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
180
+ await tick();
181
+ expect(captured).not.toBeNull();
182
+ expect(captured.detail).toEqual({ lower: 21, upper: 80 });
183
+ });
184
+
185
+ it('dual mode: lower setter clamps DOWN to upper when set too high', async () => {
186
+ const s = mount('<slider-ui dual lower-value="20" upper-value="80" min="0" max="100" step="1"></slider-ui>');
187
+ await tick();
188
+ s.lowerValue = 90; // > upperValue (80) → constraint clamps lower DOWN to upper
189
+ await tick();
190
+ expect(s.lowerValue).toBe(80);
191
+ expect(s.upperValue).toBe(80); // upper unchanged
192
+ });
193
+
194
+ it('dual mode: upper setter clamps UP to lower when set too low', async () => {
195
+ const s = mount('<slider-ui dual lower-value="20" upper-value="80" min="0" max="100" step="1"></slider-ui>');
196
+ await tick();
197
+ s.upperValue = 10; // < lowerValue (20) → constraint clamps upper UP to lower
198
+ await tick();
199
+ expect(s.upperValue).toBe(20);
200
+ expect(s.lowerValue).toBe(20); // lower unchanged
201
+ });
147
202
  });
@@ -49,9 +49,31 @@ props:
49
49
  type: string
50
50
  default: ""
51
51
  value:
52
- description: Current slider value
52
+ description: Current slider value (single-thumb mode; ignored when [dual] is set)
53
53
  type: number
54
54
  default: 50
55
+ dual:
56
+ description: |-
57
+ Two-thumb range slider mode. When enabled, [lower-value] and
58
+ [upper-value] are authoritative and [value] is ignored. The fill
59
+ renders between the two thumbs (not from left to thumb). Form-data
60
+ under [name] serializes as "<lower>,<upper>". Use for price filters,
61
+ date ranges, audio range gates, etc.
62
+ type: boolean
63
+ default: false
64
+ reflect: true
65
+ lowerValue:
66
+ description: Lower thumb value (dual mode only; clamped to ≤ [upper-value]).
67
+ type: number
68
+ default: 0
69
+ reflect: true
70
+ attribute: lower-value
71
+ upperValue:
72
+ description: Upper thumb value (dual mode only; clamped to ≥ [lower-value]).
73
+ type: number
74
+ default: 100
75
+ reflect: true
76
+ attribute: upper-value
55
77
  throttle:
56
78
  description: |-
57
79
  §184 (v0.5.5, FEEDBACK-08 §4): when > 0, debounce the `input` event by this many milliseconds. Value updates + visual feedback remain immediate; only event dispatch accumulates. Pending input flushes BEFORE `change` so consumers always see input→…→input→change ordering. throttle="0" (default) preserves the pre-§184 every-pointer-move-fires-input behavior. Common values: 50-100ms for palette regen / shader compile / large list reflow.
@@ -94,12 +116,12 @@ tokens:
94
116
  description: Full track height (scales via universal [size] attribute)
95
117
  a2ui:
96
118
  rules:
97
- - rule: 'Single-handle slider for selecting one value in a range. Form-associated; emits numeric change events.'
119
+ - rule: 'Default mode is single-handle. Form-associated; emits numeric change events with detail.value.'
98
120
  reason: 'Single-value range-input contract.'
99
- - rule: 'For two-handle range selection use <range-ui> instead.'
100
- reason: 'Single vs range sibling.'
101
- - rule: 'Step attribute controls increments; show-value enables an inline value label.'
102
- reason: 'Standard slider knobs.'
121
+ - rule: 'For two-handle range selection (price filters, date ranges, audio range gates), set [dual] and use [lower-value]/[upper-value] instead of [value]. Form-data submits as "<lower>,<upper>" under [name]; change event detail carries {lower, upper}. Do NOT use <range-ui> — that primitive is a draggable numeric field, not a two-thumb range slider.'
122
+ reason: 'Dual-thumb range selection lives on slider-ui[dual], not on a separate primitive.'
123
+ - rule: '[step] controls increments for both single and dual modes. Constraint: in dual mode, [lower-value] [upper-value] is enforced on each setter; reversed values clamp to equal.'
124
+ reason: 'Standard slider knobs + dual-mode constraint.'
103
125
  anti_patterns: []
104
126
  examples:
105
127
  - name: slider-range
@@ -326,12 +326,20 @@ export class UITable extends UIElement {
326
326
  this.#bound = true;
327
327
  this.addEventListener('click', this.#onClick);
328
328
  this.addEventListener('keydown', this.#onKeydown);
329
+ // Column resize needs pointerdown (drag start) — NOT click. click fires
330
+ // after pointerup, so by the time #startResize runs from a click
331
+ // handler, the user has already released and the document-level
332
+ // move/up listeners never trigger. pointerdown also covers touch +
333
+ // pen automatically; Playwright's page.mouse.* synthesizes pointer
334
+ // events (not mouse events), so a mousedown-only handler tests dead.
335
+ this.addEventListener('pointerdown', this.#onPointerdown);
329
336
  }
330
337
  }
331
338
 
332
339
  disconnected() {
333
340
  this.removeEventListener('click', this.#onClick);
334
341
  this.removeEventListener('keydown', this.#onKeydown);
342
+ this.removeEventListener('pointerdown', this.#onPointerdown);
335
343
  this.#bound = false;
336
344
  this.#cleanupResize();
337
345
  if (this.#renderRaf) {
@@ -1178,6 +1186,21 @@ export class UITable extends UIElement {
1178
1186
  bar.appendChild(clearAll);
1179
1187
  }
1180
1188
 
1189
+ // ── Event Handling: Pointerdown (resize-drag start) ───────────────────────
1190
+
1191
+ /** Resize-handle drag MUST start on pointerdown, not click — click fires
1192
+ * only on full down→up gestures (no drag delta), so the document-level
1193
+ * move/up listeners #startResize attaches arrive after the release.
1194
+ * pointer events cover mouse + touch + pen in one handler. */
1195
+ #onPointerdown = (e) => {
1196
+ if (e.button !== undefined && e.button !== 0) return; // primary button only
1197
+ const handle = e.target.closest('[data-resize-handle]');
1198
+ if (handle && this.contains(handle)) {
1199
+ e.preventDefault();
1200
+ this.#startResize(e, handle);
1201
+ }
1202
+ };
1203
+
1181
1204
  // ── Event Handling: Click ──────────────────────────────────────────────────
1182
1205
 
1183
1206
  #onClick = (e) => {
@@ -1355,8 +1378,8 @@ export class UITable extends UIElement {
1355
1378
 
1356
1379
  this.#resizeState = { key, col, startX, startWidth };
1357
1380
 
1358
- document.addEventListener('mousemove', this.#onResizeMove);
1359
- document.addEventListener('mouseup', this.#onResizeEnd);
1381
+ document.addEventListener('pointermove', this.#onResizeMove);
1382
+ document.addEventListener('pointerup', this.#onResizeEnd);
1360
1383
  }
1361
1384
 
1362
1385
  #onResizeMove = (e) => {
@@ -1378,8 +1401,8 @@ export class UITable extends UIElement {
1378
1401
  if (!this.#resizeState) return;
1379
1402
  const { key } = this.#resizeState;
1380
1403
 
1381
- document.removeEventListener('mousemove', this.#onResizeMove);
1382
- document.removeEventListener('mouseup', this.#onResizeEnd);
1404
+ document.removeEventListener('pointermove', this.#onResizeMove);
1405
+ document.removeEventListener('pointerup', this.#onResizeEnd);
1383
1406
 
1384
1407
  this.removeAttribute('data-resizing');
1385
1408
 
@@ -1395,8 +1418,8 @@ export class UITable extends UIElement {
1395
1418
  };
1396
1419
 
1397
1420
  #cleanupResize() {
1398
- document.removeEventListener('mousemove', this.#onResizeMove);
1399
- document.removeEventListener('mouseup', this.#onResizeEnd);
1421
+ document.removeEventListener('pointermove', this.#onResizeMove);
1422
+ document.removeEventListener('pointerup', this.#onResizeEnd);
1400
1423
  this.#resizeState = null;
1401
1424
  }
1402
1425
 
@@ -25,7 +25,11 @@
25
25
  --table-accent-default: var(--a-primary);
26
26
  --table-fg-disabled-default: var(--a-ui-text-disabled);
27
27
  --table-bg-default: var(--a-bg);
28
- --table-radius-default: var(--a-radius-lg);
28
+ /* No default rounding — table-ui composes inside card-ui / drawer-ui
29
+ which own the surface rounding; doubly-rounded corners (table inside
30
+ card) look broken. Override per-instance via `--table-radius: …` if
31
+ table-ui sits standalone on the page. */
32
+ --table-radius-default: 0;
29
33
 
30
34
  /* ── Resize + pinned-column intrinsics ── */
31
35
  --table-resize-width-default: var(--a-space-1);
@@ -103,11 +107,22 @@
103
107
  :scope {
104
108
  box-sizing: border-box;
105
109
  display: grid;
106
- overflow-x: auto;
110
+ /* `overflow: auto` (both axes) — needed so position:sticky on the
111
+ header works relative to table-ui's own scroll viewport. Vertical
112
+ scroll only kicks in when consumer sets max-height; horizontal
113
+ only when grid-template-columns sum exceeds the container width.
114
+ Pre-fix: overflow-x: auto alone made table-ui the sticky-containing
115
+ block but with no vertical clip — wrapping in an outer max-height
116
+ div made the WHOLE table scroll (header included), defeating sticky. */
117
+ overflow: auto;
107
118
  font-size: var(--table-font-size, var(--table-font-size-default));
108
119
  color: var(--table-fg, var(--table-fg-default));
109
120
  background: var(--table-bg, var(--table-bg-default));
110
- border: 1px solid var(--table-border, var(--table-border-default));
121
+ /* Use inset box-shadow as a faux border so the 1px chrome paints
122
+ inside the content box and doesn't shrink clientWidth — a real
123
+ `border: 1px` made grid content overflow by exactly 2 px on every
124
+ table (scrollbar appeared with nothing to scroll). */
125
+ box-shadow: inset 0 0 0 1px var(--table-border, var(--table-border-default));
111
126
  border-radius: var(--table-radius, var(--table-radius-default));
112
127
  position: relative;
113
128
  /* Own stacking context — sticky headers, pinned columns, and filter
@@ -119,7 +134,7 @@
119
134
 
120
135
  :scope[raw] {
121
136
  background: none;
122
- border: none;
137
+ box-shadow: none;
123
138
  border-radius: 0;
124
139
  }
125
140
 
@@ -377,6 +392,18 @@
377
392
  bottom: 0;
378
393
  width: var(--table-resize-width, var(--table-resize-width-default));
379
394
  cursor: col-resize;
395
+ /* Sit above the next column header so pointer events hit the handle
396
+ (the handle is centered on the column boundary; without z-index the
397
+ neighbor's content wins the overlap and the resize never starts). */
398
+ z-index: 2;
399
+ }
400
+
401
+ /* Hide the resize handle on the last column — it overflows 2 px past the
402
+ table's right edge (handle centered on the column boundary; the last
403
+ column has no neighbor to resize against) and triggered a 2 px scrollbar
404
+ overhang on every table even with nothing to scroll. */
405
+ [role="row"] > [role="columnheader"]:last-child [data-resize-handle] {
406
+ display: none;
380
407
  z-index: 1;
381
408
  }
382
409
 
@@ -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);
@@ -434,7 +435,9 @@ export class UITableToolbar extends UIElement {
434
435
  document.body.appendChild(panel);
435
436
 
436
437
  try { panel.showPopover(); } catch { /* popover API unavailable */ }
437
- const cleanup = anchorPopover(btn, panel, { placement: 'bottom-start', gap: 4 });
438
+ // ADR-0034 Rule 2: filter/sort/columns panels (200-320px) >> icon trigger (~32px) → center.
439
+ // Placement is internal — not consumer-overridable on table-toolbar-ui.
440
+ const cleanup = anchorPopover(btn, panel, { placement: 'bottom', gap: 4 });
438
441
 
439
442
  this.#activePopover = { kind, btn, panel, cleanup };
440
443
 
@@ -44,6 +44,16 @@
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]. Three values:\n - `solid` (default for family variants) — saturated bg + on-strong\n (near-white) text. The chip IS the state.\n - `muted` — tinted bg with scheme-paired text. Matches <badge-ui>'s\n default look. Use on metadata chips in dense lists where the\n saturated default would compete for attention.\n - `outline` — transparent bg + family-colored border + family-colored\n text. The lightest visual weight; good in dense data tables or\n faceted filter rows where multiple chips would otherwise compete.\nThe `default` variant (no family) stays quiet chrome regardless of\ntone unless `tone=\"solid\"` is set explicitly (high-contrast neutral\ninverse), or `tone=\"outline\"` (fg-muted text + subtle border).\n",
49
+ "type": "string",
50
+ "enum": [
51
+ "solid",
52
+ "muted",
53
+ "outline"
54
+ ],
55
+ "default": "solid"
56
+ },
47
57
  "variant": {
48
58
  "description": "Semantic variant — `default | info | success | warning | danger`.",
49
59
  "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,105 @@ 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` is the bright-amber step (semantics.css L3 redirect
94
+ to `-20-tint`) paired with `--a-warning-fg` (dark brown text) — the
95
+ canonical caution-tape pair. Pre-v0.6.36 this rule had a local
96
+ `-20-tint` override; that workaround moved to the token system. */
78
97
  :scope[variant="warning"] {
79
- --tag-bg-default: var(--a-warning-muted);
80
- --tag-fg-default: var(--a-warning-bg);
98
+ --tag-bg-default: var(--a-warning-bg);
99
+ --tag-fg-default: var(--a-warning-fg);
81
100
  }
82
101
 
83
102
  :scope[variant="danger"] {
84
- --tag-bg-default: var(--a-danger-muted);
85
- --tag-fg-default: var(--a-danger-bg);
103
+ --tag-bg-default: var(--a-danger-bg);
104
+ --tag-fg-default: var(--a-danger-fg);
86
105
  }
87
106
 
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. */
107
+ /* `default` (no family) stays as quiet chromea stark fg/bg-inverse
108
+ would be too loud for the no-variant case. Opt in to the inverse
109
+ stamp via `[tone="solid"]` when you want a high-contrast neutral pill. */
93
110
  :scope[variant="default"] {
94
111
  --tag-bg-default: var(--a-bg-muted);
95
112
  --tag-fg-default: var(--a-fg);
96
113
  }
97
114
 
115
+ /* ── Tone modifier — orthogonal to [variant] ──
116
+ `[tone="muted"]` opts each family variant OUT of the solid default
117
+ into the canonical muted pair: `--a-{family}-muted` (tinted surface)
118
+ + `--a-{family}-text` (scheme-flipping text). Same shape <badge-ui>
119
+ uses as its default. Use on metadata chips in dense lists where the
120
+ saturated default would compete for attention. */
121
+ :scope[tone="muted"][variant="info"] {
122
+ --tag-bg-default: var(--a-info-muted);
123
+ --tag-fg-default: var(--a-info-text);
124
+ }
125
+ :scope[tone="muted"][variant="success"] {
126
+ --tag-bg-default: var(--a-success-muted);
127
+ --tag-fg-default: var(--a-success-text);
128
+ }
129
+ :scope[tone="muted"][variant="warning"] {
130
+ --tag-bg-default: var(--a-warning-muted);
131
+ --tag-fg-default: var(--a-warning-text);
132
+ }
133
+ :scope[tone="muted"][variant="danger"] {
134
+ --tag-bg-default: var(--a-danger-muted);
135
+ --tag-fg-default: var(--a-danger-text);
136
+ }
137
+
138
+ /* `[tone="solid"]` on the neutral default (or no variant) inverts the
139
+ chrome — solid fg-color bg with bg-color text. High-contrast neutral
140
+ stamp. Explicit opt-in; the variant-less default stays quiet chrome. */
141
+ :scope[tone="solid"]:not([variant="info"]):not([variant="success"]):not([variant="warning"]):not([variant="danger"]) {
142
+ --tag-bg-default: var(--a-fg);
143
+ --tag-fg-default: var(--a-bg);
144
+ }
145
+
146
+ /* ── `[tone="outline"]` — transparent bg + family-colored border + text ──
147
+ The third common chip shape: no fill, just a ring + colored label.
148
+ Reads as "metadata that wants color recognition but minimum
149
+ visual weight" — good in dense data tables, faceted filter rows,
150
+ or anywhere multiple chips would compete if filled. */
151
+ :scope[tone="outline"] {
152
+ --tag-bg-default: transparent;
153
+ }
154
+ :scope[tone="outline"][variant="info"] {
155
+ --tag-fg-default: var(--a-info-text);
156
+ --tag-border-default: var(--a-info-border);
157
+ }
158
+ :scope[tone="outline"][variant="success"] {
159
+ --tag-fg-default: var(--a-success-text);
160
+ --tag-border-default: var(--a-success-border);
161
+ }
162
+ :scope[tone="outline"][variant="warning"] {
163
+ --tag-fg-default: var(--a-warning-text);
164
+ --tag-border-default: var(--a-warning-border);
165
+ }
166
+ :scope[tone="outline"][variant="danger"] {
167
+ --tag-fg-default: var(--a-danger-text);
168
+ --tag-border-default: var(--a-danger-border);
169
+ }
170
+ /* Outline on neutral (no family) — fg-muted text + subtle border. */
171
+ :scope[tone="outline"]:not([variant="info"]):not([variant="success"]):not([variant="warning"]):not([variant="danger"]) {
172
+ --tag-fg-default: var(--a-fg-muted);
173
+ --tag-border-default: var(--a-border);
174
+ }
175
+
98
176
  /* Size handled by universal [size] attribute system. */
99
177
 
100
178
  /* hover rule moved outside @scope — see Safari 17.x bug note at top. */
@@ -105,6 +183,15 @@ tag-ui[removable]:not([disabled]):hover {
105
183
  box-shadow: var(--tag-focus-ring, var(--tag-focus-ring-default));
106
184
  }
107
185
 
186
+ /* ── Slotted icons (leading) ──
187
+ Icons placed inside the tag (e.g. `<icon-ui name="check">`) inherit
188
+ the host's text color so legend / status chips read as a single
189
+ color-coded unit. Mirrors `<badge-ui>`'s convention. */
190
+ :scope > icon-ui {
191
+ color: currentColor;
192
+ flex-shrink: 0;
193
+ }
194
+
108
195
  /* ── Dismiss button ── */
109
196
  [slot="dismiss"] {
110
197
  display: inline-flex;
@@ -118,13 +205,14 @@ tag-ui[removable]:not([disabled]):hover {
118
205
  cursor: pointer;
119
206
  border-radius: var(--tag-dismiss-radius, var(--tag-dismiss-radius-default));
120
207
  color: var(--tag-dismiss-fg, var(--tag-dismiss-fg-default));
208
+ opacity: var(--tag-dismiss-opacity, var(--tag-dismiss-opacity-default));
121
209
  --a-icon-size: 0.875rem;
122
210
  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));
211
+ 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
212
  }
125
213
 
126
214
  [slot="dismiss"]:hover {
127
- color: var(--tag-dismiss-fg-hover, var(--tag-dismiss-fg-hover-default));
215
+ opacity: var(--tag-dismiss-opacity-hover, var(--tag-dismiss-opacity-hover-default));
128
216
  background: var(--tag-dismiss-bg-hover, var(--tag-dismiss-bg-hover-default));
129
217
  }
130
218
 
@@ -40,6 +40,20 @@ export class UITag extends UIElement {
40
40
  text: string;
41
41
  /** Tag label. Renderer routes this to the `text` attribute, rendered via CSS attr(text) on ::after. */
42
42
  textContent: string;
43
+ /** Fill style — orthogonal to [variant]. Three values:
44
+ - `solid` (default for family variants) — saturated bg + on-strong
45
+ (near-white) text. The chip IS the state.
46
+ - `muted` — tinted bg with scheme-paired text. Matches <badge-ui>'s
47
+ default look. Use on metadata chips in dense lists where the
48
+ saturated default would compete for attention.
49
+ - `outline` — transparent bg + family-colored border + family-colored
50
+ text. The lightest visual weight; good in dense data tables or
51
+ faceted filter rows where multiple chips would otherwise compete.
52
+ The `default` variant (no family) stays quiet chrome regardless of
53
+ tone unless `tone="solid"` is set explicitly (high-contrast neutral
54
+ inverse), or `tone="outline"` (fg-muted text + subtle border).
55
+ */
56
+ tone: 'solid' | 'muted' | 'outline';
43
57
  /** Semantic variant — `default | info | success | warning | danger`. */
44
58
  variant: 'default' | 'info' | 'success' | 'warning' | 'danger';
45
59
 
@@ -41,6 +41,104 @@ 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 canonical --a-warning-bg + --a-warning-fg pair', () => {
76
+ // v0.6.36 token-system fix: --a-warning-bg now redirects to
77
+ // --a-warning-20-tint at the L3 layer (semantics.css), so consumers
78
+ // can pair `-bg` + `-fg` cleanly without local overrides. The pair
79
+ // resolves to bright-amber-bg + dark-brown-text in both schemes.
80
+ expect(TAG_CSS).toMatch(
81
+ /:scope\[variant="warning"\]\s*\{[^}]*--tag-bg-default:\s*var\(--a-warning-bg\)[^}]*--tag-fg-default:\s*var\(--a-warning-fg\)/s
82
+ );
83
+ });
84
+ });
85
+
86
+ describe('tag-ui — [tone="muted"] opts out to canonical muted pair', () => {
87
+ it.each([
88
+ ['info'],
89
+ ['success'],
90
+ ['warning'],
91
+ ['danger'],
92
+ ])('[tone="muted"][variant="%s"] uses --a-{family}-muted + --a-{family}-text', (family) => {
93
+ const block = new RegExp(
94
+ `:scope\\[tone="muted"\\]\\[variant="${family}"\\]\\s*\\{[^}]*` +
95
+ `--tag-bg-default:\\s*var\\(--a-${family}-muted\\)[^}]*` +
96
+ `--tag-fg-default:\\s*var\\(--a-${family}-text\\)`,
97
+ 's'
98
+ );
99
+ expect(TAG_CSS).toMatch(block);
100
+ });
101
+ });
102
+
103
+ describe('tag-ui — neutral default + explicit solid stamp', () => {
104
+ it('[variant="default"] stays quiet chrome (--a-bg-muted + --a-fg)', () => {
105
+ expect(TAG_CSS).toMatch(
106
+ /:scope\[variant="default"\]\s*\{[^}]*--tag-bg-default:\s*var\(--a-bg-muted\)[^}]*--tag-fg-default:\s*var\(--a-fg\)/s
107
+ );
108
+ });
109
+
110
+ it('[tone="solid"] without a family inverts to fg/bg high-contrast stamp', () => {
111
+ expect(TAG_CSS).toMatch(
112
+ /:scope\[tone="solid"\]:not\(\[variant="info"\]\)[\s\S]*?--tag-bg-default:\s*var\(--a-fg\)[\s\S]*?--tag-fg-default:\s*var\(--a-bg\)/
113
+ );
114
+ });
115
+ });
116
+
117
+ describe('tag-ui — [tone="outline"] strips fill + colors border', () => {
118
+ it('[tone="outline"] base rule sets bg to transparent', () => {
119
+ expect(TAG_CSS).toMatch(
120
+ /:scope\[tone="outline"\]\s*\{[^}]*--tag-bg-default:\s*transparent/s
121
+ );
122
+ });
123
+
124
+ it.each([
125
+ ['info'],
126
+ ['success'],
127
+ ['warning'],
128
+ ['danger'],
129
+ ])('[tone="outline"][variant="%s"] colors fg + border per family', (family) => {
130
+ const block = new RegExp(
131
+ `:scope\\[tone="outline"\\]\\[variant="${family}"\\]\\s*\\{[^}]*` +
132
+ `--tag-fg-default:\\s*var\\(--a-${family}-text\\)[^}]*` +
133
+ `--tag-border-default:\\s*var\\(--a-${family}-border\\)`,
134
+ 's'
135
+ );
136
+ expect(TAG_CSS).toMatch(block);
137
+ });
138
+
139
+ it('[tone="outline"] on neutral uses --a-fg-muted text + --a-border ring', () => {
140
+ expect(TAG_CSS).toMatch(
141
+ /:scope\[tone="outline"\]:not\(\[variant="info"\]\)[\s\S]*?--tag-fg-default:\s*var\(--a-fg-muted\)[\s\S]*?--tag-border-default:\s*var\(--a-border\)/
142
+ );
45
143
  });
46
144
  });