@adia-ai/web-components 0.8.21 → 0.8.23

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 (86) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/MIGRATION.md +9 -0
  3. package/components/agent-trace/agent-trace.js +4 -1
  4. package/components/alert/alert.a2ui.json +1 -1
  5. package/components/alert/alert.css +44 -5
  6. package/components/alert/alert.yaml +8 -4
  7. package/components/canvas/canvas.js +4 -1
  8. package/components/chat-thread/chat-input.js +4 -1
  9. package/components/code/code.css +29 -26
  10. package/components/combobox/combobox.a2ui.json +1 -1
  11. package/components/combobox/combobox.class.js +14 -4
  12. package/components/combobox/combobox.css +52 -4
  13. package/components/combobox/combobox.yaml +5 -1
  14. package/components/description-list/description-list.css +9 -0
  15. package/components/drawer/drawer.css +16 -0
  16. package/components/field/field.css +4 -1
  17. package/components/list/list.css +12 -1
  18. package/components/nav-group/nav-group.class.js +45 -2
  19. package/components/nav-group/nav-group.css +11 -2
  20. package/components/progress-row/progress-row.class.js +10 -2
  21. package/components/richtext/richtext.css +7 -0
  22. package/components/select/select.a2ui.json +5 -0
  23. package/components/select/select.class.js +39 -9
  24. package/components/select/select.yaml +13 -0
  25. package/components/stat/stat.js +4 -1
  26. package/components/stepper/stepper.css +25 -20
  27. package/components/table/table.a2ui.json +15 -1
  28. package/components/table/table.class.js +14 -1
  29. package/components/table/table.css +68 -3
  30. package/components/table/table.d.ts +5 -1
  31. package/components/table/table.yaml +34 -1
  32. package/components/tabs/tab.js +4 -1
  33. package/components/tag/tag.css +15 -9
  34. package/core/provider.js +5 -1
  35. package/dist/host.min.css +1 -1
  36. package/dist/host.sheet.js +1 -1
  37. package/dist/scale.min.css +1 -1
  38. package/dist/scale.sheet.js +1 -1
  39. package/dist/theme-provider.min.js +2 -2
  40. package/dist/web-components.min.css +1 -1
  41. package/dist/web-components.min.js +93 -93
  42. package/dist/web-components.sheet.js +1 -1
  43. package/package.json +1 -1
  44. package/patterns/access-requests/access-requests.examples.html +12 -3
  45. package/patterns/admin-keys-and-export/admin-keys-and-export.examples.html +20 -5
  46. package/patterns/agent-cost/agent-cost.examples.html +12 -3
  47. package/patterns/agent-memory/agent-memory.examples.html +12 -3
  48. package/patterns/agent-prompt-library/agent-prompt-library.examples.html +12 -3
  49. package/patterns/agent-tool-call/agent-tool-call.examples.html +16 -4
  50. package/patterns/alert-dialog/alert-dialog.examples.html +12 -3
  51. package/patterns/app-nav/app-nav.examples.html +36 -9
  52. package/patterns/approvals/approvals.examples.html +14 -10
  53. package/patterns/audit-log/audit-log.examples.html +5 -2
  54. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +210 -32
  55. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.js +83 -4
  56. package/patterns/bulk-action-toolbar/bulk-action-toolbar.html +68 -0
  57. package/patterns/changelog-feed/changelog-feed.examples.html +4 -1
  58. package/patterns/chart-in-card/chart-in-card.examples.html +28 -7
  59. package/patterns/comments-and-collaboration/comments-and-collaboration.examples.html +20 -5
  60. package/patterns/conversion-funnel/conversion-funnel.examples.html +24 -15
  61. package/patterns/data-tables-inline-edit-and-tree/data-tables-inline-edit-and-tree.examples.html +15 -6
  62. package/patterns/diff-review/diff-review.examples.html +12 -3
  63. package/patterns/export-flow/export-flow.examples.html +12 -3
  64. package/patterns/filter-bar/filter-bar.examples.html +13 -4
  65. package/patterns/form-drawer/form-drawer.examples.html +15 -6
  66. package/patterns/form-system/form-system.examples.html +4 -1
  67. package/patterns/forms-conditional-and-autocomplete/forms-conditional-and-autocomplete.examples.html +12 -3
  68. package/patterns/inline-dialog/inline-dialog.examples.html +12 -3
  69. package/patterns/kanban-board/kanban-board.examples.html +4 -1
  70. package/patterns/marketing-engagement/marketing-engagement.examples.html +24 -6
  71. package/patterns/notifications-bell-and-digest/notifications-bell-and-digest.examples.html +12 -3
  72. package/patterns/permissions-matrix/permissions-matrix.examples.html +9 -3
  73. package/patterns/permissions-role-picker/permissions-role-picker.examples.html +8 -2
  74. package/patterns/permissions-sharing/permissions-sharing.examples.html +12 -3
  75. package/patterns/profile-public-and-verification/profile-public-and-verification.examples.html +16 -4
  76. package/patterns/record-detail-drawer/record-detail-drawer.examples.html +10 -4
  77. package/patterns/retention-cohort/retention-cohort.examples.html +15 -6
  78. package/patterns/search-discovery/search-discovery.examples.html +16 -4
  79. package/patterns/system-banners/system-banners.examples.html +54 -48
  80. package/patterns/timezone-selector/timezone-selector.examples.html +8 -2
  81. package/patterns/timezone-selector/timezone-selector.examples.js +3 -1
  82. package/patterns/wizard-drawer/wizard-drawer.examples.html +25 -23
  83. package/patterns/wizard-drawer/wizard-drawer.examples.js +12 -1
  84. package/styles/foundation/radius.css +2 -2
  85. package/styles/scale.css +2 -2
  86. package/styles/type/scale.css +6 -6
@@ -48,6 +48,14 @@ export class UINavGroup extends UIElement {
48
48
  #popover = null;
49
49
  #anchorCleanup = null;
50
50
  #headerEl = null;
51
+ // gh#501 — the gh#459 ruling ("the group CONTAINING the selected route
52
+ // fills") self-enforces: watch descendant nav-item [selected] flips and
53
+ // membership changes so the header glyph fills without consumer wiring.
54
+ // Targets ONLY the weight sync — a full render() from the observer loops:
55
+ // render's textContent writes are themselves subtree childList mutations.
56
+ // Stable ref for symmetric disconnect.
57
+ #onChildSelection = () => this.#syncHeaderWeight();
58
+ #selectionObserver = null;
51
59
 
52
60
  #onHeaderKey = (e) => {
53
61
  if (!this.collapsible) return;
@@ -80,6 +88,21 @@ export class UINavGroup extends UIElement {
80
88
  }
81
89
 
82
90
  this.#headerEl?.addEventListener('keydown', this.#onHeaderKey);
91
+
92
+ // gh#501 — fill-on-child-selection needs no consumer wiring: observe
93
+ // [selected] flips on descendants + child list churn. Watching the
94
+ // whole subtree includes this host's own [selected], which render()
95
+ // reads anyway; render() writes only the icon's [weight], which the
96
+ // attributeFilter excludes, so the observer can't feed back.
97
+ if (!this.#selectionObserver) {
98
+ this.#selectionObserver = new MutationObserver(this.#onChildSelection);
99
+ this.#selectionObserver.observe(this, {
100
+ subtree: true,
101
+ childList: true,
102
+ attributes: true,
103
+ attributeFilter: ['selected'],
104
+ });
105
+ }
83
106
  }
84
107
 
85
108
  render() {
@@ -99,9 +122,27 @@ export class UINavGroup extends UIElement {
99
122
  // path; [open] keeps driving aria-expanded only. A state-driven weight
100
123
  // swap, not a CSS color change, since Phosphor ships filled/regular as
101
124
  // distinct SVGs.
125
+ this.#syncHeaderWeight();
126
+ }
127
+
128
+ // Own [selected] OR any selected descendant item (gh#501) — the ruling's
129
+ // "group CONTAINING the selected route", self-enforcing. Skips the write
130
+ // when the weight is already right so the observer path stays quiet.
131
+ #syncHeaderWeight() {
132
+ const containsSelection =
133
+ this.hasAttribute('selected') || !!this.querySelector('nav-item-ui[selected]');
134
+ // Reflect contains-selection for CSS (gh#522): the icon COLOR half of
135
+ // the selection language keys on this host attribute — weight alone
136
+ // read as an unselected white glyph beside nav-item's primary one.
137
+ // The observer's attributeFilter is ['selected'], so this write on
138
+ // the host cannot feed back.
139
+ if (containsSelection !== this.hasAttribute('data-selected-within')) {
140
+ this.toggleAttribute('data-selected-within', containsSelection);
141
+ }
102
142
  const iconEl = this.querySelector('[slot="header"] [slot="icon"] icon-ui');
103
- if (iconEl)
104
- iconEl.setAttribute('weight', this.hasAttribute('selected') ? 'fill' : 'regular');
143
+ if (!iconEl) return;
144
+ const weight = containsSelection ? 'fill' : 'regular';
145
+ if (iconEl.getAttribute('weight') !== weight) iconEl.setAttribute('weight', weight);
105
146
  }
106
147
 
107
148
  showPopover() {
@@ -165,5 +206,7 @@ export class UINavGroup extends UIElement {
165
206
  this.#anchorCleanup?.();
166
207
  this.#anchorCleanup = null;
167
208
  this.#popover?.hidePopover?.();
209
+ this.#selectionObserver?.disconnect();
210
+ this.#selectionObserver = null;
168
211
  }
169
212
  }
@@ -14,6 +14,7 @@
14
14
  --nav-group-fg-hover: var(--a-fg-strong);
15
15
  --nav-group-fg-muted: var(--a-fg-muted);
16
16
  --nav-group-fg-selected: var(--a-fg-strong);
17
+ --nav-group-icon-fg-selected: var(--md-sys-color-primary); /* one selection color with --nav-item-accent (gh#522) */
17
18
  --nav-group-bg-hover: var(--a-bg-muted);
18
19
  --nav-group-icon-size: calc(var(--nav-group-row-height) - var(--a-space-2));
19
20
  --nav-group-badge-size: var(--a-ui-sm);
@@ -52,10 +53,18 @@
52
53
  /* Selected-look header treatment keys on [selected] — the CSS half of
53
54
  gh issue 459 (PR 460 corrected the icon weight): the group CONTAINING
54
55
  the selected route reads strong; a merely-expanded group does not. */
55
- :scope[selected] > [slot="header"],
56
- :scope[selected] > [slot="header"] [slot="icon"] {
56
+ :scope[selected] > [slot="header"] {
57
57
  color: var(--nav-group-fg-selected);
58
58
  }
59
+ /* Icon color = the SELECTION color, one language with nav-item's accent
60
+ glyph (gh#522, operator ruling 2026-07-30): filled + primary whenever
61
+ this group is selected OR contains the selected route.
62
+ [data-selected-within] is reflected by #syncHeaderWeight (gh#501) so
63
+ the JS-detected contains-selection state is CSS-addressable. */
64
+ :scope[selected] > [slot="header"] [slot="icon"],
65
+ :scope[data-selected-within] > [slot="header"] [slot="icon"] {
66
+ color: var(--nav-group-icon-fg-selected);
67
+ }
59
68
 
60
69
  /* Header row */
61
70
  [slot="header"] {
@@ -91,8 +91,16 @@ export class UIProgressRow extends UIElement {
91
91
  this.#labelEl.textContent = this.label;
92
92
  this.#labelEl.hidden = !this.label;
93
93
 
94
- this.#metaEl.textContent = this.meta;
95
- this.#metaEl.hidden = !this.meta;
94
+ // The [meta] ATTR owns the text when set. When it's absent, authored
95
+ // [slot="meta"] content — e.g. meta text with a trailing delta badge
96
+ // (gh#502) — is left intact instead of being wiped + hidden; only a
97
+ // genuinely empty meta element hides.
98
+ if (this.meta) {
99
+ this.#metaEl.textContent = this.meta;
100
+ this.#metaEl.hidden = false;
101
+ } else {
102
+ this.#metaEl.hidden = !this.#metaEl.hasChildNodes();
103
+ }
96
104
 
97
105
  if (this.value != null && this.value >= 0) {
98
106
  this.#progressEl.setAttribute('value', String(this.value));
@@ -96,6 +96,13 @@
96
96
  width: 100%;
97
97
  max-width: var(--richtext-max-width);
98
98
  padding: var(--richtext-py) var(--richtext-px);
99
+ /* Body spec set ONCE at the container (gh#546): p was pinned to
100
+ --richtext-body-size while li/ul had no font rule and inherited the
101
+ HOST page's larger base — chat-rendered lists read bigger than the
102
+ paragraph beside them. Everything now inherits this; headings/code
103
+ still override explicitly. */
104
+ font-size: var(--richtext-body-size);
105
+ line-height: var(--richtext-body-leading);
99
106
  }
100
107
 
101
108
  /* [flush] — paragraph-grade embedding (yaml `flush` prop): drop the
@@ -106,6 +106,11 @@
106
106
  "type": "string",
107
107
  "default": ""
108
108
  },
109
+ "no-radio": {
110
+ "description": "Suppress the single-select radio indicator on option rows (gh#498). For menu-shaped switchers — workspace or user identity menus — where the trigger already shows the committed value and a per-row radio reads as redundant selection language. Rows keep their layout and aria-selected state; the trigger and selected-row weight carry the signal. No effect under [multiple]. IMPLIED by [mark] — a mark declares an identity switcher, which is always menu-shaped (gh#498 reopen: per-instance opt-outs proved forgettable).",
111
+ "type": "boolean",
112
+ "default": false
113
+ },
109
114
  "open": {
110
115
  "description": "Whether the listbox is open",
111
116
  "type": "boolean",
@@ -53,6 +53,10 @@ export class UISelect extends UIFormElement {
53
53
  searchable: { type: Boolean, default: false, reflect: true },
54
54
  freeText: { type: Boolean, default: false, reflect: true, attribute: 'free-text' },
55
55
  divider: { type: Boolean, default: false, reflect: true },
56
+ // gh#498 — suppress the single-select radio indicator for menu-shaped
57
+ // switchers (workspace/user identity menus) where the trigger already
58
+ // carries the committed value; rows keep layout + aria-selected.
59
+ noRadio: { type: Boolean, default: false, reflect: true, attribute: 'no-radio' },
56
60
  // §184 (v0.5.5, FEEDBACK-08 §7): optional caption beneath the
57
61
  // trigger, wired to aria-describedby on the host.
58
62
  hint: { type: String, default: '', reflect: true },
@@ -541,8 +545,12 @@ export class UISelect extends UIFormElement {
541
545
  ? new Set(this.value.split(',').map((s) => s.trim()).filter(Boolean))
542
546
  : null;
543
547
  for (const opt of this.#listbox.querySelectorAll('[role="option"]')) {
548
+ // Action rows have no data-value (they are commands — see the
549
+ // stamping note); null must never compare equal to a value, and
550
+ // `null === ''` is already false, but the explicit guard keeps a
551
+ // future `v ?? ''` normalization from resurrecting the leak.
544
552
  const v = opt.getAttribute('data-value');
545
- const selected = selSet ? selSet.has(v) : v === this.value;
553
+ const selected = v !== null && (selSet ? selSet.has(v) : v === this.value);
546
554
  if (selected) opt.setAttribute('aria-selected', 'true');
547
555
  else opt.removeAttribute('aria-selected');
548
556
  }
@@ -766,12 +774,19 @@ export class UISelect extends UIFormElement {
766
774
  }
767
775
  const el = document.createElement('div');
768
776
  el.setAttribute('role', 'option');
769
- el.setAttribute('data-value', opt.value || '');
777
+ // Action rows carry NO [data-value]: they are commands, not values.
778
+ // Stamping `opt.value || ''` gave them data-value="" — and since
779
+ // `this.value` ALSO defaults to '', the aria-selected sync marked
780
+ // every action row selected whenever nothing was committed (the
781
+ // default state of a user menu — reviewer finding, 2026-07-29).
782
+ // With the attribute absent, getAttribute() returns null and no
783
+ // value comparison can ever match.
784
+ if (!opt.action) el.setAttribute('data-value', opt.value || '');
770
785
  // §FB-46: [multiple] value is comma-separated — use Set membership.
771
786
  const selSet = this.multiple
772
787
  ? new Set(this.value.split(',').map((s) => s.trim()).filter(Boolean))
773
788
  : null;
774
- const isSelected = selSet ? selSet.has(opt.value) : opt.value === this.value;
789
+ const isSelected = !opt.action && (selSet ? selSet.has(opt.value) : opt.value === this.value);
775
790
  // SPEC-040 — multi-select option rows render a leading checkbox
776
791
  // indicator (CSS-driven via [data-multi-option]); the `check` icon
777
792
  // shows when aria-selected="true".
@@ -806,11 +821,22 @@ export class UISelect extends UIFormElement {
806
821
  // Single-select mirrors the multi structure with a RADIO indicator
807
822
  // (operator ruling 2026-07-27) — selection is carried by the
808
823
  // indicator, not a row tint; CSS keys off [data-single-option].
824
+ // [no-radio] (gh#498) drops only the dot: identity-switcher menus
825
+ // read the committed value from the trigger, so the per-row radio
826
+ // is redundant there; row layout + aria-selected are unchanged.
827
+ // [mark] IMPLIES it (gh#498 reopen, 2026-07-30): mark declares an
828
+ // identity switcher, and the first fix proved per-instance
829
+ // attributes get forgotten — the playground got [no-radio], the
830
+ // site chrome switcher shipped radios anyway. The semantic
831
+ // attribute is the reliable signal; [no-radio] remains for
832
+ // markless menus.
809
833
  el.setAttribute('data-single-option', '');
810
- const dot = document.createElement('span');
811
- dot.setAttribute('data-radio', '');
812
- dot.setAttribute('aria-hidden', 'true');
813
- el.appendChild(dot);
834
+ if (!(this.noRadio || this.mark)) {
835
+ const dot = document.createElement('span');
836
+ dot.setAttribute('data-radio', '');
837
+ dot.setAttribute('aria-hidden', 'true');
838
+ el.appendChild(dot);
839
+ }
814
840
  const label = document.createElement('span');
815
841
  label.setAttribute('data-option-label', '');
816
842
  if (lead) label.innerHTML = `${lead}${escapeHTML(opt.label)}`;
@@ -1042,11 +1068,15 @@ export class UISelect extends UIFormElement {
1042
1068
 
1043
1069
  #applyFilter() {
1044
1070
  if (!this.#listbox) return;
1045
- const q = this.#query.toLowerCase();
1071
+ // Underscores normalize to spaces on BOTH sides (gh#538): IANA-style
1072
+ // labels ("America/Los_Angeles") must match the query a human types
1073
+ // ("los angeles") — and vice versa.
1074
+ const norm = (t) => t.toLowerCase().replace(/_/g, ' ');
1075
+ const q = norm(this.#query);
1046
1076
  const opts = this.#listbox.querySelectorAll('[role="option"]');
1047
1077
  let anyVisible = false;
1048
1078
  for (const opt of opts) {
1049
- const label = (opt.textContent || '').toLowerCase();
1079
+ const label = norm(opt.textContent || '');
1050
1080
  const match = !q || label.includes(q);
1051
1081
  if (match) {
1052
1082
  opt.removeAttribute('data-filtered-out');
@@ -74,6 +74,19 @@ props:
74
74
  type: boolean
75
75
  default: false
76
76
  reflect: true
77
+ no-radio:
78
+ description: >-
79
+ Suppress the single-select radio indicator on option rows (gh#498).
80
+ For menu-shaped switchers — workspace or user identity menus — where
81
+ the trigger already shows the committed value and a per-row radio
82
+ reads as redundant selection language. Rows keep their layout and
83
+ aria-selected state; the trigger and selected-row weight carry the
84
+ signal. No effect under [multiple]. IMPLIED by [mark] — a mark
85
+ declares an identity switcher, which is always menu-shaped
86
+ (gh#498 reopen: per-instance opt-outs proved forgettable).
87
+ type: boolean
88
+ default: false
89
+ reflect: true
77
90
  error:
78
91
  description: Validation error message
79
92
  type: string
@@ -8,6 +8,7 @@
8
8
  * Trend: up, down, neutral (colors the change badge accordingly)
9
9
  */
10
10
 
11
+ import { defineIfFree } from '../../core/register.js';
11
12
  import { UIElement } from '../../core/element.js';
12
13
 
13
14
  class UIStat extends UIElement {
@@ -119,6 +120,8 @@ class UIStat extends UIElement {
119
120
  this.#iconEl = null;
120
121
  }
121
122
  }
122
- customElements.define('stat-ui', UIStat);
123
+ // defineIfFree (gh#534): the prod bundle + module graph load in parallel;
124
+ // second registration must no-op, not throw.
125
+ defineIfFree('stat-ui', UIStat);
123
126
 
124
127
  export { UIStat };
@@ -13,20 +13,24 @@
13
13
  :where(:scope) {
14
14
  /* ── Circle ── */
15
15
  --stepper-size: 2rem;
16
- --stepper-bg: var(--md-sys-color-neutral-background);
17
- --stepper-border: var(--md-sys-color-neutral-outline);
18
- --stepper-fg: var(--a-fg-muted);
16
+ /* Operator stepper system (2026-07-30): next = transparent circle on a
17
+ low-emphasis outline; current = filled primary; completed = tonal
18
+ primary-container with a primary glyph. Borders on the two emphasized
19
+ states are transparent — the fill IS the affordance. */
20
+ --stepper-bg: transparent;
21
+ --stepper-border: var(--md-sys-color-neutral-outline-variant);
22
+ --stepper-fg: var(--md-sys-color-neutral-on-surface-variant);
19
23
  --stepper-font: var(--a-ui-sm);
20
24
  --stepper-weight: var(--a-weight-medium);
21
25
  --stepper-border-size: 2px;
22
26
 
23
- --stepper-bg-active: var(--md-sys-color-neutral-background);
24
- --stepper-border-active: var(--md-sys-color-primary);
25
- --stepper-fg-active: var(--md-sys-color-primary);
27
+ --stepper-bg-active: var(--md-sys-color-primary);
28
+ --stepper-border-active: transparent;
29
+ --stepper-fg-active: var(--md-sys-color-primary-on-primary);
26
30
 
27
- --stepper-bg-done: var(--md-sys-color-primary);
28
- --stepper-border-done: var(--md-sys-color-primary);
29
- --stepper-fg-done: var(--md-sys-color-primary-on-primary);
31
+ --stepper-bg-done: var(--md-sys-color-primary-container);
32
+ --stepper-border-done: transparent;
33
+ --stepper-fg-done: var(--md-sys-color-primary);
30
34
 
31
35
  --stepper-line: var(--md-sys-color-neutral-outline-variant);
32
36
  --stepper-line-done: var(--md-sys-color-primary);
@@ -69,20 +73,20 @@
69
73
  co-located item legitimately reads parent tokens (the same
70
74
  scope pattern as menu-item-ui inside menu-ui's popover). */
71
75
  --stepper-item-size: var(--stepper-size, 2rem);
72
- --stepper-item-bg: var(--stepper-bg, var(--md-sys-color-neutral-background));
73
- --stepper-item-border: var(--stepper-border, var(--md-sys-color-neutral-outline));
74
- --stepper-item-fg: var(--stepper-fg, var(--a-fg-muted));
76
+ --stepper-item-bg: var(--stepper-bg, transparent);
77
+ --stepper-item-border: var(--stepper-border, var(--md-sys-color-neutral-outline-variant));
78
+ --stepper-item-fg: var(--stepper-fg, var(--md-sys-color-neutral-on-surface-variant));
75
79
  --stepper-item-font: var(--stepper-font, var(--a-ui-sm));
76
80
  --stepper-item-weight: var(--stepper-weight, var(--a-weight-medium));
77
81
  --stepper-item-border-size: var(--stepper-border-size, 2px);
78
82
 
79
- --stepper-item-bg-active: var(--stepper-bg-active, var(--md-sys-color-neutral-background));
80
- --stepper-item-border-active: var(--stepper-border-active, var(--md-sys-color-primary));
81
- --stepper-item-fg-active: var(--stepper-fg-active, var(--md-sys-color-primary));
83
+ --stepper-item-bg-active: var(--stepper-bg-active, var(--md-sys-color-primary));
84
+ --stepper-item-border-active: var(--stepper-border-active, transparent);
85
+ --stepper-item-fg-active: var(--stepper-fg-active, var(--md-sys-color-primary-on-primary));
82
86
 
83
- --stepper-item-bg-done: var(--stepper-bg-done, var(--md-sys-color-primary));
84
- --stepper-item-border-done: var(--stepper-border-done, var(--md-sys-color-primary));
85
- --stepper-item-fg-done: var(--stepper-fg-done, var(--md-sys-color-primary-on-primary));
87
+ --stepper-item-bg-done: var(--stepper-bg-done, var(--md-sys-color-primary-container));
88
+ --stepper-item-border-done: var(--stepper-border-done, transparent);
89
+ --stepper-item-fg-done: var(--stepper-fg-done, var(--md-sys-color-primary));
86
90
 
87
91
  --stepper-item-line: var(--stepper-line, var(--md-sys-color-neutral-outline-variant));
88
92
  --stepper-item-line-done: var(--stepper-line-done, var(--md-sys-color-primary));
@@ -99,7 +103,8 @@
99
103
  --stepper-item-label-weight: var(--a-weight-medium);
100
104
  --stepper-item-label-size: var(--a-ui-size);
101
105
  --stepper-item-label-fg: var(--md-sys-color-neutral-on-surface);
102
- --stepper-item-pending-fg: var(--a-fg-muted);
106
+ --stepper-item-label-fg-done: var(--md-sys-color-neutral-on-surface-variant);
107
+ --stepper-item-pending-fg: var(--md-sys-color-neutral-on-surface-variant);
103
108
  --stepper-item-desc-fg: var(--md-sys-color-neutral-on-surface-variant);
104
109
  --stepper-item-desc-size: var(--a-ui-sm);
105
110
 
@@ -181,7 +186,7 @@
181
186
  }
182
187
 
183
188
  :scope[status="completed"] [slot="label"] {
184
- color: var(--stepper-item-label-fg);
189
+ color: var(--stepper-item-label-fg-done);
185
190
  }
186
191
 
187
192
  /* Pending label */
@@ -14,7 +14,7 @@
14
14
  ],
15
15
  "properties": {
16
16
  "columns": {
17
- "description": "Column definitions. Array of {key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?, filterable?, pinned?, hidden?, wrap?, accessor?, format?, render?, sortFn?, filterType?, meta?}. Alternative to declarative <col-def> children.",
17
+ "description": "Column definitions. Array of {key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?, filterable?, pinned?, hidden?, wrap?, valign?, accessor?, format?, render?, sortFn?, filterType?, meta?}. `valign` ('top'|'center') overrides the host [valign] for that column's body cells only — header cells always stay centered. Alternative to declarative <col-def> children (<col-def valign=\"top\">).",
18
18
  "$ref": "common_types.json#/$defs/DynamicStringList"
19
19
  },
20
20
  "component": {
@@ -39,6 +39,11 @@
39
39
  "type": "boolean",
40
40
  "default": false
41
41
  },
42
+ "frameless": {
43
+ "description": "Drop the table's outer 1px perimeter and nothing else (gh#511) — row dividers are untouched (the perimeter is split onto its own --table-perimeter token, defaulting to --table-border). For card-composed tables: the card owns the frame, so the table's square perimeter otherwise renders a doubled edge (flush bleed) or a stray inner box (with content above). Standalone tables keep their chrome by default. Theming path without the attribute: set --table-perimeter to transparent per instance.",
44
+ "type": "boolean",
45
+ "default": false
46
+ },
42
47
  "loading": {
43
48
  "description": "Renders N ghost skeleton rows in place of the body data (count derived from `paginate` if set, else 5). Header + columns stay intact so the table layout is preserved while data fetches. Sets aria-busy=\"true\" on the host. Data updates are deferred until loading is set back to false.",
44
49
  "type": "boolean",
@@ -74,6 +79,15 @@
74
79
  "type": "boolean",
75
80
  "default": false
76
81
  },
82
+ "valign": {
83
+ "description": "Vertical alignment of body-cell content. 'center' (default) keeps today's behavior — every cell centers on the row's cross-axis, which reads well for single-line rows. 'top' aligns each cell's content to the top of its padding box instead, so tall/wrapped rows (a [wrap] title column, a stacked identity cell) share one first-line scan anchor across the row rather than each cell floating at a different vertical center (gh#505). Per-column `valign` in the columns[] array (or <col-def valign=\"…\">) overrides the host per-column — e.g. keep a checkbox column centered while data cells top-align. Header cells always stay centered regardless of this prop (single-line by contract) — the checkbox/expand structural columns also default to centered under [valign=\"top\"] since they're not part of columns[].",
84
+ "type": "string",
85
+ "enum": [
86
+ "top",
87
+ "center"
88
+ ],
89
+ "default": "center"
90
+ },
77
91
  "wrap": {
78
92
  "description": "Allow body-cell content to wrap onto multiple lines. Default is single-line with ellipsis truncation (matches the row convention shared by <select-ui> + <nav-item-ui>); long unbreakable strings clip gracefully rather than rewrapping the row. With [wrap], row height auto-grows to fit wrapped content. For surgical opt-in on a single column, set [data-wrap] on the cell / col-def instead.",
79
93
  "type": "boolean",
@@ -22,6 +22,7 @@
22
22
  * selectable — enable row selection via checkboxes
23
23
  * striped — alternate row background
24
24
  * density — 'compact' | 'standard' | 'comfortable'
25
+ * valign — 'center' (default) | 'top' — body-cell vertical alignment (gh#505)
25
26
  * paginate — rows per page (0 = no pagination)
26
27
  * loading — show loading overlay
27
28
  * search — global search filter string
@@ -33,7 +34,7 @@
33
34
  *
34
35
  * JS API:
35
36
  * .columns = [{key, label, type, width, minWidth, maxWidth, flex,
36
- * sortable, filterable, resizable, pinned, hidden,
37
+ * sortable, filterable, resizable, pinned, hidden, valign,
37
38
  * accessor, format, render, sortFn, sortDescFirst,
38
39
  * filterType, filterFn, aggregate, meta}]
39
40
  * .data = [{...}, ...]
@@ -111,6 +112,10 @@ export class UITable extends UIElement {
111
112
  selectable: { type: Boolean, default: false, reflect: true },
112
113
  expandable: { type: Boolean, default: false, reflect: true },
113
114
  striped: { type: Boolean, default: false, reflect: true },
115
+ valign: { type: String, default: 'center', reflect: true },
116
+ // gh#511 — drop the outer perimeter only (row dividers stay); the
117
+ // card+bleed composition's "let the container own the frame" knob.
118
+ frameless: { type: Boolean, default: false, reflect: true },
114
119
  wrap: { type: Boolean, default: false, reflect: true },
115
120
  raw: { type: Boolean, default: false, reflect: true },
116
121
  density: { type: String, default: 'standard', reflect: true },
@@ -412,6 +417,7 @@ export class UITable extends UIElement {
412
417
  resizable: el.hasAttribute('resizable'),
413
418
  filterable: el.hasAttribute('filterable'),
414
419
  filterType: el.getAttribute('filter-type') || null,
420
+ valign: el.getAttribute('valign') || null, // 'top' | 'center' | null (inherit host)
415
421
  };
416
422
 
417
423
  const w = el.getAttribute('width');
@@ -896,6 +902,13 @@ export class UITable extends UIElement {
896
902
  cell.dataset.align = typeDef.align;
897
903
  }
898
904
 
905
+ // Vertical alignment — per-column override of the host [valign] (gh#505).
906
+ // Only stamped when the column explicitly opts in; unset falls through
907
+ // to the host's --table-valign token.
908
+ if (col.valign) {
909
+ cell.dataset.valign = col.valign;
910
+ }
911
+
899
912
  // Pinned
900
913
  if (col.pinned) {
901
914
  cell.setAttribute('data-pinned', col.pinned);
@@ -13,10 +13,26 @@
13
13
  --table-fg-hover: var(--a-fg-strong);
14
14
  --table-fg-secondary: var(--a-fg-muted);
15
15
  --table-border: var(--md-sys-color-neutral-outline-variant);
16
+ /* Perimeter split off the divider token (gh#511): --table-border feeds
17
+ BOTH the outer frame and the row dividers (TKT-0026), so consumers
18
+ could never drop the frame without erasing dividers. Inside card-ui
19
+ set --table-perimeter: transparent (or use [frameless]) — the card
20
+ owns the frame; standalone tables keep their chrome by default. */
21
+ --table-perimeter: var(--table-border);
16
22
  --table-border-strong: var(--md-sys-color-neutral-outline);
17
23
  --table-py: var(--a-space-3);
18
24
  --table-px: var(--a-space-4);
19
25
 
26
+ /* Body-cell vertical alignment mode (gh#505). 'center' is the default,
27
+ unconditional behavior every consumer already ships against — this
28
+ token flips to flex-start when [valign="top"] (below). Uniform
29
+ padding (--table-py) IS the first-line alignment story: every body
30
+ cell's content box starts flush at the same y-offset, so a chip in
31
+ one column and a wrapped title's first line in another share one
32
+ scan anchor, rather than each cell floating at its own vertical
33
+ center on a tall row. */
34
+ --table-valign: center;
35
+
20
36
  --table-row-bg: transparent;
21
37
  /* container-low, not the generic --a-bg-hover state-layer token
22
38
  (TKT-0020) — --a-bg-hover / --md-sys-color-neutral-hover is tuned
@@ -130,7 +146,15 @@
130
146
  inside the content box and doesn't shrink clientWidth — a real
131
147
  `border: 1px` made grid content overflow by exactly 2 px on every
132
148
  table (scrollbar appeared with nothing to scroll). */
133
- box-shadow: inset 0 0 0 1px var(--table-border);
149
+ /* Three-sided perimeter no TOP edge (gh#537, operator ruling
150
+ 2026-07-30): the header row's own border-bottom is the table's top
151
+ structure; a line above the header reads as a stray divider under
152
+ a card edge or empty-state. Sides + bottom keep the standalone
153
+ silhouette; [frameless] still blanks all of it. */
154
+ box-shadow:
155
+ inset 1px 0 0 var(--table-perimeter),
156
+ inset -1px 0 0 var(--table-perimeter),
157
+ inset 0 -1px 0 var(--table-perimeter);
134
158
  border-radius: var(--table-radius);
135
159
  position: relative;
136
160
  /* Own stacking context — sticky headers, pinned columns, and filter
@@ -176,6 +200,9 @@
176
200
 
177
201
  /* ═══════ Column headers ═══════ */
178
202
 
203
+ /* align-items:center is hardcoded, NOT --table-valign (gh#505) — header
204
+ cells are single-line by contract regardless of [valign] on the host
205
+ or any column. Do not swap this to the var. */
179
206
  [role="columnheader"] {
180
207
  box-sizing: border-box;
181
208
  text-align: left;
@@ -222,7 +249,9 @@
222
249
  color: var(--table-sort-fg);
223
250
  margin-inline-start: var(--table-sort-icon-gap);
224
251
  vertical-align: middle;
225
- --a-icon-size: 0.75em;
252
+ /* 0.95em ≈ +2px over the former 0.75em at the heading's --a-ui-xs size
253
+ (gh#496) — em-based so the caret keeps scaling with the register. */
254
+ --a-icon-size: 0.95em;
226
255
  }
227
256
 
228
257
  [aria-sort] [data-sort-icon] {
@@ -247,7 +276,7 @@
247
276
  [data-body] [role="gridcell"] {
248
277
  box-sizing: border-box;
249
278
  display: flex;
250
- align-items: center;
279
+ align-items: var(--table-valign);
251
280
  align-self: stretch;
252
281
  padding: var(--table-py) var(--table-px);
253
282
  min-width: 0;
@@ -331,6 +360,42 @@
331
360
  justify-content: center;
332
361
  }
333
362
 
363
+ /* ═══════ Vertical alignment (gh#505) ═══════ */
364
+
365
+ :scope[valign="top"] {
366
+ --table-valign: flex-start;
367
+ }
368
+
369
+ /* ═══════ Frameless (gh#511) ═══════
370
+ Drops ONLY the perimeter faux-border; row dividers keep reading
371
+ --table-border. For card-composed tables, where the card owns the
372
+ frame and the table's square perimeter otherwise double-borders. */
373
+ :scope[frameless] {
374
+ --table-perimeter: transparent;
375
+ }
376
+
377
+ /* Per-column override — beats the host var regardless of [valign], in
378
+ either direction (top column in a center table, or vice versa).
379
+ Higher specificity than the base `[data-body] [role="gridcell"]`
380
+ rule above (:249) so it wins without relying on source order. */
381
+ [data-body] [role="gridcell"][data-valign="top"] {
382
+ align-items: flex-start;
383
+ }
384
+
385
+ [data-body] [role="gridcell"][data-valign="center"] {
386
+ align-items: center;
387
+ }
388
+
389
+ /* Structural columns (checkbox / expand-toggle) are never part of
390
+ columns[] so authors can't set [data-valign] on them — they stay
391
+ centered under [valign="top"] by default (gh#505's checkbox example),
392
+ matching the icon/control's own fixed small size regardless of row
393
+ height. */
394
+ [data-body] [role="gridcell"][data-check-col],
395
+ [data-body] [role="gridcell"][data-expand-col] {
396
+ align-items: center;
397
+ }
398
+
334
399
  /* ═══════ Row states ═══════ */
335
400
 
336
401
  [data-body] > [role="row"] {
@@ -86,7 +86,7 @@ export interface TableSortEventDetail {
86
86
  export type TableSortEvent = CustomEvent<TableSortEventDetail>;
87
87
 
88
88
  export class UITable extends UIElement {
89
- /** Column definitions. Array of {key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?, filterable?, pinned?, hidden?, wrap?, accessor?, format?, render?, sortFn?, filterType?, meta?}. Alternative to declarative <col-def> children. */
89
+ /** Column definitions. Array of {key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?, filterable?, pinned?, hidden?, wrap?, valign?, accessor?, format?, render?, sortFn?, filterType?, meta?}. `valign` ('top'|'center') overrides the host [valign] for that column's body cells only — header cells always stay centered. Alternative to declarative <col-def> children (<col-def valign="top">). */
90
90
  columns: string;
91
91
  /** JS property (set programmatically — `el.data = [...]`) — row records, an array of plain objects keyed to columns[].key. May also be supplied declaratively as a JSON-array `data="[…]"` attribute, hydrated once at connect (gh#288) — the same pattern chart-ui's `data` attribute uses. Custom accessor on the element class, not a reflected attribute. */
92
92
  data: string;
@@ -94,6 +94,8 @@ export class UITable extends UIElement {
94
94
  density: 'compact' | 'standard' | 'comfortable';
95
95
  /** Enable row expansion */
96
96
  expandable: boolean;
97
+ /** Drop the table's outer 1px perimeter and nothing else (gh#511) — row dividers are untouched (the perimeter is split onto its own --table-perimeter token, defaulting to --table-border). For card-composed tables: the card owns the frame, so the table's square perimeter otherwise renders a doubled edge (flush bleed) or a stray inner box (with content above). Standalone tables keep their chrome by default. Theming path without the attribute: set --table-perimeter to transparent per instance. */
98
+ frameless: boolean;
97
99
  /** Renders N ghost skeleton rows in place of the body data (count derived from `paginate` if set, else 5). Header + columns stay intact so the table layout is preserved while data fetches. Sets aria-busy="true" on the host. Data updates are deferred until loading is set back to false. */
98
100
  loading: boolean;
99
101
  /** Rows per page. 0 = show all rows without pagination. When > 0, renders a pagination bar below the table. */
@@ -108,6 +110,8 @@ export class UITable extends UIElement {
108
110
  sortable: boolean;
109
111
  /** Alternate row background colors for visual scanning. */
110
112
  striped: boolean;
113
+ /** Vertical alignment of body-cell content. 'center' (default) keeps today's behavior — every cell centers on the row's cross-axis, which reads well for single-line rows. 'top' aligns each cell's content to the top of its padding box instead, so tall/wrapped rows (a [wrap] title column, a stacked identity cell) share one first-line scan anchor across the row rather than each cell floating at a different vertical center (gh#505). Per-column `valign` in the columns[] array (or <col-def valign="…">) overrides the host per-column — e.g. keep a checkbox column centered while data cells top-align. Header cells always stay centered regardless of this prop (single-line by contract) — the checkbox/expand structural columns also default to centered under [valign="top"] since they're not part of columns[]. */
114
+ valign: 'top' | 'center';
111
115
  /** Allow body-cell content to wrap onto multiple lines. Default is single-line with ellipsis truncation (matches the row convention shared by <select-ui> + <nav-item-ui>); long unbreakable strings clip gracefully rather than rewrapping the row. With [wrap], row height auto-grows to fit wrapped content. For surgical opt-in on a single column, set [data-wrap] on the cell / col-def instead. */
112
116
  wrap: boolean;
113
117
 
@@ -20,7 +20,7 @@ composes:
20
20
  - badge-ui
21
21
  props:
22
22
  columns:
23
- description: Column definitions. Array of {key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?, filterable?, pinned?, hidden?, wrap?, accessor?, format?, render?, sortFn?, filterType?, meta?}. Alternative to declarative <col-def> children.
23
+ description: Column definitions. Array of {key, label, type?, width?, minWidth?, maxWidth?, flex?, sortable?, resizable?, filterable?, pinned?, hidden?, wrap?, valign?, accessor?, format?, render?, sortFn?, filterType?, meta?}. `valign` ('top'|'center') overrides the host [valign] for that column's body cells only — header cells always stay centered. Alternative to declarative <col-def> children (<col-def valign="top">).
24
24
  type: array
25
25
  default: []
26
26
  dynamic: true # JS-set collection prop with a custom setter — deliberately not in static properties
@@ -98,6 +98,39 @@ props:
98
98
  description: Alternate row background colors for visual scanning.
99
99
  type: boolean
100
100
  default: false
101
+ frameless:
102
+ description: >-
103
+ Drop the table's outer 1px perimeter and nothing else (gh#511) —
104
+ row dividers are untouched (the perimeter is split onto its own
105
+ --table-perimeter token, defaulting to --table-border). For
106
+ card-composed tables: the card owns the frame, so the table's
107
+ square perimeter otherwise renders a doubled edge (flush bleed) or
108
+ a stray inner box (with content above). Standalone tables keep
109
+ their chrome by default. Theming path without the attribute:
110
+ set --table-perimeter to transparent per instance.
111
+ type: boolean
112
+ default: false
113
+ reflect: true
114
+ valign:
115
+ description: >-
116
+ Vertical alignment of body-cell content. 'center' (default) keeps
117
+ today's behavior — every cell centers on the row's cross-axis, which
118
+ reads well for single-line rows. 'top' aligns each cell's content to
119
+ the top of its padding box instead, so tall/wrapped rows (a [wrap]
120
+ title column, a stacked identity cell) share one first-line scan
121
+ anchor across the row rather than each cell floating at a different
122
+ vertical center (gh#505). Per-column `valign` in the columns[] array
123
+ (or <col-def valign="…">) overrides the host per-column — e.g. keep a
124
+ checkbox column centered while data cells top-align. Header cells
125
+ always stay centered regardless of this prop (single-line by
126
+ contract) — the checkbox/expand structural columns also default to
127
+ centered under [valign="top"] since they're not part of columns[].
128
+ type: string
129
+ default: center
130
+ enum:
131
+ - top
132
+ - center
133
+ reflect: true
101
134
  wrap:
102
135
  description: >-
103
136
  Allow body-cell content to wrap onto multiple lines. Default is