@adia-ai/web-components 0.6.36 → 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 (115) hide show
  1. package/CHANGELOG.md +28 -1
  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/context-menu/context-menu.a2ui.json +159 -0
  20. package/components/context-menu/context-menu.class.js +275 -0
  21. package/components/context-menu/context-menu.css +56 -0
  22. package/components/context-menu/context-menu.d.ts +70 -0
  23. package/components/context-menu/context-menu.js +17 -0
  24. package/components/context-menu/context-menu.yaml +136 -0
  25. package/components/date-range-picker/date-range-picker.a2ui.json +15 -0
  26. package/components/date-range-picker/date-range-picker.class.js +2 -0
  27. package/components/date-range-picker/date-range-picker.yaml +14 -0
  28. package/components/datetime-picker/datetime-picker.a2ui.json +15 -0
  29. package/components/datetime-picker/datetime-picker.class.js +3 -1
  30. package/components/datetime-picker/datetime-picker.d.ts +2 -0
  31. package/components/datetime-picker/datetime-picker.yaml +14 -0
  32. package/components/empty-state/empty-state.class.js +2 -0
  33. package/components/feed/feed.class.js +13 -5
  34. package/components/feed/feed.css +14 -0
  35. package/components/index.js +9 -0
  36. package/components/integration-card/integration-card.class.js +9 -0
  37. package/components/integration-card/integration-card.test.js +4 -3
  38. package/components/nav-group/nav-group.css +7 -1
  39. package/components/number-format/number-format.a2ui.json +180 -0
  40. package/components/number-format/number-format.class.js +96 -0
  41. package/components/number-format/number-format.css +18 -0
  42. package/components/number-format/number-format.d.ts +68 -0
  43. package/components/number-format/number-format.js +17 -0
  44. package/components/number-format/number-format.yaml +204 -0
  45. package/components/pagination/pagination.a2ui.json +19 -2
  46. package/components/pagination/pagination.class.js +90 -37
  47. package/components/pagination/pagination.css +32 -127
  48. package/components/pagination/pagination.d.ts +8 -2
  49. package/components/pagination/pagination.test.js +195 -0
  50. package/components/pagination/pagination.yaml +22 -1
  51. package/components/password-strength/password-strength.a2ui.json +152 -0
  52. package/components/password-strength/password-strength.class.js +157 -0
  53. package/components/password-strength/password-strength.css +80 -0
  54. package/components/password-strength/password-strength.d.ts +59 -0
  55. package/components/password-strength/password-strength.js +17 -0
  56. package/components/password-strength/password-strength.yaml +153 -0
  57. package/components/popover/popover.css +43 -23
  58. package/components/popover/popover.yaml +8 -4
  59. package/components/qr-code/QR-TEST.svg +4 -0
  60. package/components/qr-code/qr-code.a2ui.json +154 -0
  61. package/components/qr-code/qr-code.class.js +129 -0
  62. package/components/qr-code/qr-code.css +41 -0
  63. package/components/qr-code/qr-code.d.ts +83 -0
  64. package/components/qr-code/qr-code.js +17 -0
  65. package/components/qr-code/qr-code.yaml +203 -0
  66. package/components/qr-code/qr-encoder.js +633 -0
  67. package/components/relative-time/relative-time.a2ui.json +120 -0
  68. package/components/relative-time/relative-time.class.js +136 -0
  69. package/components/relative-time/relative-time.css +22 -0
  70. package/components/relative-time/relative-time.d.ts +51 -0
  71. package/components/relative-time/relative-time.js +17 -0
  72. package/components/relative-time/relative-time.yaml +133 -0
  73. package/components/segmented/segmented.class.js +5 -1
  74. package/components/select/select.class.js +4 -0
  75. package/components/skip-nav/skip-nav.a2ui.json +92 -0
  76. package/components/skip-nav/skip-nav.class.js +45 -0
  77. package/components/skip-nav/skip-nav.css +54 -0
  78. package/components/skip-nav/skip-nav.d.ts +27 -0
  79. package/components/skip-nav/skip-nav.js +12 -0
  80. package/components/skip-nav/skip-nav.yaml +68 -0
  81. package/components/slider/slider.a2ui.json +16 -1
  82. package/components/slider/slider.class.js +264 -122
  83. package/components/slider/slider.css +82 -2
  84. package/components/slider/slider.d.ts +19 -3
  85. package/components/slider/slider.test.js +55 -0
  86. package/components/slider/slider.yaml +28 -6
  87. package/components/table/table.class.js +29 -6
  88. package/components/table/table.css +31 -4
  89. package/components/table-toolbar/table-toolbar.class.js +3 -1
  90. package/components/tag/tag.a2ui.json +3 -2
  91. package/components/tag/tag.css +35 -11
  92. package/components/tag/tag.d.ts +14 -0
  93. package/components/tag/tag.test.js +35 -11
  94. package/components/tag/tag.yaml +13 -7
  95. package/components/toast/toast.class.js +12 -4
  96. package/components/toc/toc.a2ui.json +159 -0
  97. package/components/toc/toc.class.js +222 -0
  98. package/components/toc/toc.css +92 -0
  99. package/components/toc/toc.d.ts +61 -0
  100. package/components/toc/toc.js +17 -0
  101. package/components/toc/toc.yaml +180 -0
  102. package/components/toolbar/toolbar.class.js +3 -0
  103. package/components/visually-hidden/visually-hidden.a2ui.json +71 -0
  104. package/components/visually-hidden/visually-hidden.class.js +14 -0
  105. package/components/visually-hidden/visually-hidden.css +25 -0
  106. package/components/visually-hidden/visually-hidden.d.ts +26 -0
  107. package/components/visually-hidden/visually-hidden.js +12 -0
  108. package/components/visually-hidden/visually-hidden.yaml +54 -0
  109. package/core/anchor.js +19 -3
  110. package/dist/web-components.min.css +1 -1
  111. package/dist/web-components.min.js +100 -89
  112. package/package.json +1 -1
  113. package/styles/colors/semantics.css +11 -2
  114. package/styles/components.css +9 -0
  115. 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
 
@@ -435,7 +435,9 @@ export class UITableToolbar extends UIElement {
435
435
  document.body.appendChild(panel);
436
436
 
437
437
  try { panel.showPopover(); } catch { /* popover API unavailable */ }
438
- 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 });
439
441
 
440
442
  this.#activePopover = { kind, btn, panel, cleanup };
441
443
 
@@ -45,11 +45,12 @@
45
45
  "$ref": "common_types.json#/$defs/DynamicString"
46
46
  },
47
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",
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
49
  "type": "string",
50
50
  "enum": [
51
51
  "solid",
52
- "muted"
52
+ "muted",
53
+ "outline"
53
54
  ],
54
55
  "default": "solid"
55
56
  },
@@ -90,18 +90,12 @@ tag-ui[removable]:not([disabled]):hover {
90
90
  --tag-fg-default: var(--a-success-fg);
91
91
  }
92
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. */
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. */
103
97
  :scope[variant="warning"] {
104
- --tag-bg-default: var(--a-warning-20-tint);
98
+ --tag-bg-default: var(--a-warning-bg);
105
99
  --tag-fg-default: var(--a-warning-fg);
106
100
  }
107
101
 
@@ -149,6 +143,36 @@ tag-ui[removable]:not([disabled]):hover {
149
143
  --tag-fg-default: var(--a-bg);
150
144
  }
151
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
+
152
176
  /* Size handled by universal [size] attribute system. */
153
177
 
154
178
  /* hover rule moved outside @scope — see Safari 17.x bug note at top. */
@@ -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
 
@@ -72,18 +72,13 @@ describe('tag-ui — variant defaults to solid fill', () => {
72
72
  expect(TAG_CSS).toMatch(block);
73
73
  });
74
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.
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.
81
80
  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
81
+ /:scope\[variant="warning"\]\s*\{[^}]*--tag-bg-default:\s*var\(--a-warning-bg\)[^}]*--tag-fg-default:\s*var\(--a-warning-fg\)/s
87
82
  );
88
83
  });
89
84
  });
@@ -118,3 +113,32 @@ describe('tag-ui — neutral default + explicit solid stamp', () => {
118
113
  );
119
114
  });
120
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
+ );
143
+ });
144
+ });
@@ -53,18 +53,24 @@ props:
53
53
  - danger
54
54
  tone:
55
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).
56
+ Fill style — orthogonal to [variant]. Three values:
57
+ - `solid` (default for family variants) saturated bg + on-strong
58
+ (near-white) text. The chip IS the state.
59
+ - `muted` — tinted bg with scheme-paired text. Matches <badge-ui>'s
60
+ default look. Use on metadata chips in dense lists where the
61
+ saturated default would compete for attention.
62
+ - `outline` transparent bg + family-colored border + family-colored
63
+ text. The lightest visual weight; good in dense data tables or
64
+ faceted filter rows where multiple chips would otherwise compete.
65
+ The `default` variant (no family) stays quiet chrome regardless of
66
+ tone unless `tone="solid"` is set explicitly (high-contrast neutral
67
+ inverse), or `tone="outline"` (fg-muted text + subtle border).
63
68
  type: string
64
69
  default: solid
65
70
  enum:
66
71
  - solid
67
72
  - muted
73
+ - outline
68
74
  events:
69
75
  remove:
70
76
  description: Fired when the dismiss button is activated.
@@ -78,10 +78,14 @@ export class UIToast extends UIElement {
78
78
  * @param {string} [opts.variant='info'] — `error` aliases to `danger`.
79
79
  * @param {number} [opts.duration=4000]
80
80
  * @param {string} [opts.position='bottom-right']
81
+ * @param {boolean} [opts.dismissible] Force-show the close button on auto-
82
+ * fade toasts (sticky toasts always show it).
83
+ * @param {string} [opts.action] Optional action button label (e.g. 'Undo').
84
+ * @param {function} [opts.onAction] Action button click handler.
81
85
  * @returns {{id:string|null, dismiss:function, update:function}} FeedHandle.
82
86
  */
83
87
  static show(opts = {}) {
84
- const { text, variant = 'info', duration = 4000, position = 'bottom-right' } = opts;
88
+ const { text, variant = 'info', position = 'bottom-right', action, onAction } = opts;
85
89
  // §224 (v0.5.9, FEEDBACK-10 §2): mark the spawned feed container so
86
90
  // DOM-query consumers (Playwright, devtools, instrumentation) can
87
91
  // distinguish toast-spawned <feed-ui> from user-authored ones. The marker
@@ -91,11 +95,15 @@ export class UIToast extends UIElement {
91
95
  if (container && !container.hasAttribute('data-spawned-by')) {
92
96
  container.setAttribute('data-spawned-by', 'toast');
93
97
  }
94
- return UIFeed.post({
98
+ const payload = {
95
99
  text,
96
100
  variant: variant === 'error' ? 'danger' : variant,
97
- duration,
98
101
  position,
99
- });
102
+ };
103
+ if ('duration' in opts) payload.duration = opts.duration;
104
+ if ('dismissible' in opts) payload.dismissible = !!opts.dismissible;
105
+ if (action) payload.action = action;
106
+ if (typeof onAction === 'function') payload.onAction = onAction;
107
+ return UIFeed.post(payload);
100
108
  }
101
109
  }