@adia-ai/web-components 0.6.50 → 0.7.0

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 (100) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/components/action-list/action-list.css +1 -1
  3. package/components/agent-artifact/agent-artifact.class.js +10 -10
  4. package/components/agent-artifact/agent-artifact.css +1 -1
  5. package/components/agent-reasoning/agent-reasoning.class.js +51 -0
  6. package/components/agent-reasoning/agent-reasoning.css +49 -22
  7. package/components/alert/alert.class.js +8 -1
  8. package/components/alert/alert.css +13 -1
  9. package/components/avatar/avatar.a2ui.json +2 -14
  10. package/components/avatar/avatar.class.js +3 -15
  11. package/components/avatar/avatar.d.ts +2 -4
  12. package/components/avatar/avatar.yaml +1 -18
  13. package/components/breadcrumb/breadcrumb.css +4 -1
  14. package/components/button/button.a2ui.json +3 -0
  15. package/components/button/button.css +14 -3
  16. package/components/button/button.yaml +5 -0
  17. package/components/calendar-grid/calendar-grid.css +1 -1
  18. package/components/calendar-picker/calendar-picker.css +5 -2
  19. package/components/chart/chart.a2ui.json +0 -18
  20. package/components/chart/chart.class.js +8 -50
  21. package/components/chart/chart.css +1 -15
  22. package/components/chart/chart.d.ts +0 -4
  23. package/components/chart/chart.yaml +0 -24
  24. package/components/color-input/color-input.css +4 -1
  25. package/components/combobox/combobox.class.js +11 -0
  26. package/components/combobox/combobox.css +8 -0
  27. package/components/date-range-picker/date-range-picker.class.js +5 -1
  28. package/components/date-range-picker/date-range-picker.css +12 -2
  29. package/components/datetime-picker/datetime-picker.class.js +3 -0
  30. package/components/datetime-picker/datetime-picker.css +16 -2
  31. package/components/empty-state/empty-state.css +11 -4
  32. package/components/field/field.css +17 -6
  33. package/components/grid/grid.a2ui.json +5 -0
  34. package/components/grid/grid.class.js +16 -6
  35. package/components/grid/grid.css +17 -3
  36. package/components/grid/grid.d.ts +2 -0
  37. package/components/grid/grid.yaml +9 -0
  38. package/components/heatmap/heatmap.class.js +9 -3
  39. package/components/heatmap/heatmap.css +19 -2
  40. package/components/image/image.css +4 -1
  41. package/components/input/input.css +1 -1
  42. package/components/integration-card/integration-card.class.js +31 -7
  43. package/components/integration-card/integration-card.test.js +12 -1
  44. package/components/kbd/kbd.a2ui.json +3 -2
  45. package/components/kbd/kbd.css +7 -4
  46. package/components/kbd/kbd.d.ts +2 -2
  47. package/components/kbd/kbd.yaml +2 -1
  48. package/components/list/list.class.js +8 -1
  49. package/components/menu/menu.css +4 -1
  50. package/components/modal/modal.class.js +10 -1
  51. package/components/modal/modal.css +9 -0
  52. package/components/option-card/option-card.a2ui.json +3 -0
  53. package/components/option-card/option-card.css +44 -19
  54. package/components/option-card/option-card.yaml +5 -0
  55. package/components/otp-input/otp-input.css +25 -10
  56. package/components/page/page.css +64 -11
  57. package/components/pagination/pagination.class.js +1 -1
  58. package/components/pagination/pagination.css +9 -1
  59. package/components/pipeline-status/pipeline-status.css +6 -0
  60. package/components/popover/popover.css +12 -1
  61. package/components/preview/preview.css +30 -3
  62. package/components/progress-row/progress-row.css +3 -1
  63. package/components/qr-code/qr-code.css +4 -1
  64. package/components/segmented/segmented.css +4 -1
  65. package/components/select/select.a2ui.json +1 -1
  66. package/components/select/select.class.js +63 -7
  67. package/components/select/select.css +18 -0
  68. package/components/select/select.yaml +9 -2
  69. package/components/stack/stack.a2ui.json +12 -1
  70. package/components/stack/stack.d.ts +2 -2
  71. package/components/stack/stack.yaml +13 -1
  72. package/components/stat/stat.a2ui.json +5 -0
  73. package/components/stat/stat.css +55 -0
  74. package/components/stat/stat.d.ts +2 -0
  75. package/components/stat/stat.js +4 -0
  76. package/components/stat/stat.yaml +9 -0
  77. package/components/swiper/swiper.class.js +14 -6
  78. package/components/switch/switch.css +13 -0
  79. package/components/table/table.a2ui.json +2 -2
  80. package/components/table/table.css +13 -1
  81. package/components/table/table.yaml +2 -2
  82. package/components/time-picker/time-picker.css +4 -1
  83. package/components/timeline/timeline.class.js +3 -3
  84. package/components/timeline/timeline.css +23 -5
  85. package/components/toggle-group/toggle-group.css +4 -1
  86. package/components/toggle-scheme/toggle-scheme.css +4 -1
  87. package/dist/web-components.min.css +1 -1
  88. package/dist/web-components.min.js +81 -81
  89. package/package.json +3 -3
  90. package/styles/api/layout.css +7 -0
  91. package/styles/api/text.css +9 -5
  92. package/styles/index.css +11 -2
  93. package/styles/prose.css +8 -0
  94. package/styles/resets.css +5 -5
  95. package/styles/themes.css +8 -1
  96. package/styles/tokens.css +3 -3
  97. package/styles/type/elements.css +73 -0
  98. package/styles/type/roles.css +14 -49
  99. package/styles/type/scale.css +0 -5
  100. package/styles/typography.css +3 -3
@@ -9,6 +9,15 @@
9
9
  /* ── Padding default (when [padding] is set without a value) ── */
10
10
  --page-padding-default: var(--a-space-6);
11
11
 
12
+ /* ── Region rhythm — vertical gap between header / section / footer,
13
+ mirroring card-ui's section inset. The page [padding] supplies the
14
+ outer frame; this is the inter-region spacing. ── */
15
+ --page-inset-default: var(--a-space-5);
16
+
17
+ /* ── Padded-region sub-surface (a section[padding] inside the page) ── */
18
+ --page-region-bg-default: var(--a-bg-subtle);
19
+ --page-region-radius-default: var(--a-radius-md);
20
+
12
21
  /* ── Surfaces ── */
13
22
  --page-bg-default: var(--a-canvas-0);
14
23
  --page-fg-default: var(--a-fg);
@@ -23,6 +32,9 @@
23
32
  box-sizing: border-box;
24
33
  display: block;
25
34
  width: 100%;
35
+ /* --page-pad carries the resolved [padding] value so the sticky header can
36
+ bleed back over it (card-ui pattern) — see the sticky-header rule. */
37
+ padding: var(--page-pad, 0);
26
38
  background: var(--page-bg, var(--page-bg-default));
27
39
  color: var(--page-fg, var(--page-fg-default));
28
40
  }
@@ -33,17 +45,47 @@
33
45
  :scope[max-width="wide"] { max-width: var(--page-max-width-wide, var(--page-max-width-wide-default)); margin-inline: auto; }
34
46
  :scope[max-width="full"] { max-width: var(--page-max-width-full, var(--page-max-width-full-default)); }
35
47
 
36
- /* ── Padding scale (mirrors --a-space-N) ── */
37
- :scope[padding=""] { padding: var(--page-padding-default); }
38
- :scope[padding="0"] { padding: 0; }
39
- :scope[padding="1"] { padding: var(--a-space-1); }
40
- :scope[padding="2"] { padding: var(--a-space-2); }
41
- :scope[padding="3"] { padding: var(--a-space-3); }
42
- :scope[padding="4"] { padding: var(--a-space-4); }
43
- :scope[padding="5"] { padding: var(--a-space-5); }
44
- :scope[padding="6"] { padding: var(--a-space-6); }
45
- :scope[padding="7"] { padding: var(--a-space-7); }
46
- :scope[padding="8"] { padding: var(--a-space-8); }
48
+ /* ── Padding scale (mirrors --a-space-N) — sets --page-pad, applied above ── */
49
+ :scope[padding=""] { --page-pad: var(--page-padding-default); }
50
+ :scope[padding="0"] { --page-pad: 0; }
51
+ :scope[padding="1"] { --page-pad: var(--a-space-1); }
52
+ :scope[padding="2"] { --page-pad: var(--a-space-2); }
53
+ :scope[padding="3"] { --page-pad: var(--a-space-3); }
54
+ :scope[padding="4"] { --page-pad: var(--a-space-4); }
55
+ :scope[padding="5"] { --page-pad: var(--a-space-5); }
56
+ :scope[padding="6"] { --page-pad: var(--a-space-6); }
57
+ :scope[padding="7"] { --page-pad: var(--a-space-7); }
58
+ :scope[padding="8"] { --page-pad: var(--a-space-8); }
59
+
60
+ /* ═══════ Region model — header / section / footer ═══════
61
+ Mirrors card-ui's section model, adapted to the page's [padding] frame:
62
+ the page [padding] is the OUTER inset; these rules add the vertical
63
+ rhythm BETWEEN regions plus the [bleed] / [padding] modifiers. The
64
+ page's own @scope styling the slot primitives is what the docs promise
65
+ ([<header>] / [<section>] / [<footer>] and their -ui variants). */
66
+
67
+ /* Vertical rhythm: every region after the first picks up a top margin so
68
+ header → body → footer breathe consistently (the first region hugs the
69
+ padding edge). */
70
+ :scope > :where(header, header-ui, section, section-ui, footer, footer-ui)
71
+ ~ :where(header, header-ui, section, section-ui, footer, footer-ui) {
72
+ margin-block-start: var(--page-inset, var(--page-inset-default));
73
+ }
74
+
75
+ /* [bleed] — edge-to-edge region: cancel the page's inline [padding] so
76
+ full-width content (hero, banner, table, chart) reaches the page edges.
77
+ Resolves to 0 (no-op) when the page has no padding. */
78
+ :scope > :where(header, header-ui, section, section-ui, footer, footer-ui)[bleed] {
79
+ margin-inline: calc(-1 * var(--page-pad, 0));
80
+ }
81
+
82
+ /* [padding] on a region — a padded sub-surface with its own background +
83
+ radius, like card-ui's section[padding]. */
84
+ :scope > :where(section, section-ui)[padding] {
85
+ padding: var(--page-inset, var(--page-inset-default));
86
+ background: var(--page-region-bg, var(--page-region-bg-default));
87
+ border-radius: var(--page-region-radius, var(--page-region-radius-default));
88
+ }
47
89
 
48
90
  /* ── Scroll container ── */
49
91
  :scope[scroll] {
@@ -53,11 +95,22 @@
53
95
  }
54
96
 
55
97
  /* ── Sticky-header support ── */
98
+ /* Drop the page's TOP padding when the header is sticky — that gap is where
99
+ scrolling content used to peek ABOVE the pinned header. The header then
100
+ sits flush at the scroll-container top and supplies its own top spacing
101
+ (padding-block below). */
102
+ :scope[sticky-header] { padding-top: 0; }
56
103
  :scope[sticky-header] > :where(header, header-ui) {
57
104
  position: sticky;
58
105
  top: 0;
59
106
  z-index: 1;
60
107
  background: var(--page-sticky-bg, var(--page-sticky-bg-default));
108
+ /* Bleed horizontally over the page's inline [padding] so the opaque band
109
+ spans the full scroll width, then re-inset the content so it stays
110
+ aligned with the body. Vertical = its own breathing room. Card-ui
111
+ header pattern. (No negative margin-top — that breaks position:sticky.) */
112
+ margin-inline: calc(-1 * var(--page-pad, 0));
113
+ padding: var(--a-space-3) var(--page-pad, 0);
61
114
  transition: border-color var(--a-duration-fast) var(--a-easing), box-shadow var(--a-duration-fast) var(--a-easing);
62
115
  }
63
116
 
@@ -37,7 +37,7 @@ export class UIPagination extends UIElement {
37
37
  size: { type: String, default: 'md', reflect: true },
38
38
  };
39
39
 
40
- // Phosphor icons stamped by this primitive (prev/next chevrons inside
40
+ // Phosphor icons stamped by this primitive (prev/next carets inside
41
41
  // the nested <button-ui>). Audited by check-required-icons.mjs.
42
42
  static requiredIcons = ['caret-left', 'caret-right'];
43
43
 
@@ -23,9 +23,12 @@
23
23
  :scope {
24
24
  /* ── Base ── */
25
25
  box-sizing: border-box;
26
- display: inline-flex;
26
+ display: flex;
27
27
  }
28
28
 
29
+ /* Display convention (ADR-0037): block-level by default; [inline] opts back to inline-level. */
30
+ :scope[inline] { display: inline-flex; }
31
+
29
32
  /* ── Nav container ── */
30
33
  [slot="nav"] {
31
34
  display: flex;
@@ -66,5 +69,10 @@
66
69
  aspect-ratio 1 here for the bordered-cell look). */
67
70
  :scope[variant="button"] [slot="nav"] button-ui {
68
71
  aspect-ratio: 1;
72
+ /* 1:1 cells inherit button-ui's --a-radius-md (~13px), which on a square
73
+ cell is ~37% of the side → a circle, not the "square 1:1 bordered button"
74
+ the variant promises. Tighten to -sm so the bordered cells read as rounded
75
+ squares. Same square+large-radius trap as otp-input (bug-37 / bug-39). */
76
+ --button-radius: var(--a-radius-sm);
69
77
  }
70
78
  }
@@ -96,11 +96,17 @@
96
96
  transition: background var(--pipeline-status-duration, var(--pipeline-status-duration-default)) var(--pipeline-status-easing, var(--pipeline-status-easing-default));
97
97
  }
98
98
 
99
+ /* State colors: the -active (accent) / -complete (success) dot-bg tokens were
100
+ defined but never applied — these rules only set animation, so every dot
101
+ stayed the default gray (--a-border). Now active dots read accent + pulse,
102
+ complete dots read success. (bug-40 — "use color more") */
99
103
  [data-pipeline-dot="active"] {
104
+ background: var(--pipeline-status-dot-bg-active, var(--pipeline-status-dot-bg-active-default));
100
105
  animation: pipeline-pulse var(--pipeline-status-pulse-duration, var(--pipeline-status-pulse-duration-default)) var(--pipeline-status-pulse-easing, var(--pipeline-status-pulse-easing-default)) infinite;
101
106
  }
102
107
 
103
108
  [data-pipeline-dot="complete"] {
109
+ background: var(--pipeline-status-dot-bg-complete, var(--pipeline-status-dot-bg-complete-default));
104
110
  animation: none;
105
111
  }
106
112
 
@@ -14,10 +14,13 @@
14
14
 
15
15
  :scope {
16
16
  box-sizing: border-box;
17
- display: inline-flex;
17
+ display: flex;
18
18
  position: relative;
19
19
  }
20
20
 
21
+ /* Display convention (ADR-0037): block-level by default; [inline] opts back to inline-level. */
22
+ :scope[inline] { display: inline-flex; }
23
+
21
24
  [slot="trigger"] {
22
25
  display: inline-flex;
23
26
  }
@@ -81,6 +84,14 @@
81
84
  box-shadow: var(--popover-shadow, var(--popover-shadow-default));
82
85
  }
83
86
 
87
+ /* Prose margin reset on the content's edge children — a slotted <p> / <h*>
88
+ carries the UA stylesheet's margin-block (~1em), which lands inside the
89
+ panel padding and adds asymmetric top/bottom space (a single-line <p> looked
90
+ like it had a stray bottom margin). Zero the leading/trailing margins so the
91
+ panel padding alone frames the content. (bug — Popover single-line space) */
92
+ [slot="content"] > :first-child { margin-block-start: 0; }
93
+ [slot="content"] > :last-child { margin-block-end: 0; }
94
+
84
95
  [slot="content"]:popover-open {
85
96
  @starting-style {
86
97
  opacity: 0;
@@ -39,16 +39,42 @@
39
39
  by the frame's overflow:hidden. Most demos fit (the component's overflow
40
40
  check stacks split→full-width first), so the scrollbar only appears when a
41
41
  demo is genuinely wider than the docs column. */
42
+ /* Column flow is the default: each example stacks in DOM order and fills the
43
+ stage width (align-items: stretch), so block-level demos (progress / bars /
44
+ accordion / cards / pages …) read at full width without a per-component
45
+ list. Text-flow atoms (button / badge / tag / …) opt back to content-width
46
+ via align-self below. (bug-46 — replaces the old flex-row + :only-child
47
+ block-flow hack, which only filled single-child demos.) */
42
48
  [data-preview-render] {
43
49
  display: flex;
44
- flex-wrap: wrap;
45
- align-items: center;
50
+ flex-direction: column;
51
+ align-items: stretch;
46
52
  gap: var(--preview-render-gap, var(--preview-render-gap-default));
47
53
  padding: var(--preview-render-pad, var(--preview-render-pad-default));
48
54
  background: var(--preview-render-bg, var(--preview-render-bg-default));
49
55
  overflow-x: auto;
50
56
  }
51
-
57
+ /* Lone text-flow atoms shouldn't span the whole stage — keep them
58
+ content-width + leading-aligned (the ADR-0037 inline-display set). */
59
+ [data-preview-render] > :is(button-ui, badge-ui, tag-ui, chip-ui, kbd-ui, icon-ui, swatch-ui, spinner-ui, switch-ui, avatar-ui) {
60
+ align-self: start;
61
+ }
62
+ /* Form-field demos collapse to content-width as flex items in the render
63
+ cell — a placeholder-only combobox shrank to a ~52px "Sel…" stub — so
64
+ stretch form controls to the cell width with width:100%.
65
+ • <field-ui> is a full-width form ROW: let it fill the whole stage,
66
+ NO measure cap. The prior 28rem cap read as "not using available
67
+ space" on wide single-block demos (bug-55 follow-up).
68
+ • Bare atomic controls (input/select/…) keep the 28rem measure cap so
69
+ a lone control doesn't sprawl into an unrealistic bar on a wide stage.
70
+ Non-form demos (buttons, badges, etc.) are untouched — content-width. */
71
+ [data-preview-render] field-ui {
72
+ width: 100%;
73
+ }
74
+ [data-preview-render] > :is(input-ui, select-ui, combobox-ui, textarea-ui, tags-input-ui, color-input-ui) {
75
+ width: 100%;
76
+ max-width: 28rem;
77
+ }
52
78
  /* Code pane — divider line between panes; flatten the nested code-ui
53
79
  chrome so the preview frame owns the outer border + radius. */
54
80
  [data-preview-code] {
@@ -151,6 +177,7 @@
151
177
  color: var(--a-fg-muted);
152
178
  background: var(--a-bg-muted);
153
179
  padding: 0.125rem 0.375rem;
180
+ margin-bottom: 0.5rem;
154
181
  border-radius: var(--a-radius-sm);
155
182
  }
156
183
  /* Narrow: collapse each row to stacked render-over-code. */
@@ -1,7 +1,9 @@
1
1
  @scope (progress-row-ui) {
2
2
  :where(:scope) {
3
3
  /* ── Tokens ── */
4
- --progress-row-gap-default: var(--a-space-1);
4
+ /* Row-gap between the label/meta row and the bar. Bumped --a-space-1
5
+ --a-space-2 so the bar isn't crammed under the label. (bug-42) */
6
+ --progress-row-gap-default: var(--a-space-2);
5
7
  --progress-row-column-gap-default: var(--a-space-2);
6
8
  --progress-row-label-size-default: var(--a-ui-size);
7
9
  --progress-row-label-fg-default: var(--a-fg);
@@ -10,7 +10,7 @@
10
10
 
11
11
  :scope {
12
12
  box-sizing: border-box;
13
- display: inline-block;
13
+ display: block;
14
14
  /* The SVG itself carries explicit width/height; this is a fallback
15
15
  for cases where the SVG hasn't stamped yet (empty value/matrix). */
16
16
  line-height: 0;
@@ -18,6 +18,9 @@
18
18
  background: var(--qr-code-bg, var(--qr-code-bg-default));
19
19
  }
20
20
 
21
+ /* Display convention (ADR-0037): block-level by default; [inline] opts back to inline-level. */
22
+ :scope[inline] { display: inline-block; }
23
+
21
24
  :scope svg {
22
25
  display: block;
23
26
  /* Width / height come from the explicit SVG attributes set by JS
@@ -28,7 +28,7 @@
28
28
  :scope {
29
29
  /* ── Base ── */
30
30
  box-sizing: border-box;
31
- display: inline-grid;
31
+ display: grid;
32
32
  grid-auto-flow: column;
33
33
  grid-auto-columns: 1fr;
34
34
  align-items: stretch;
@@ -55,6 +55,9 @@
55
55
  border-radius: var(--segmented-radius, var(--segmented-radius-default));
56
56
  }
57
57
 
58
+ /* Display convention (ADR-0037): block-level by default; [inline] opts back to inline-level. */
59
+ :scope[inline] { display: inline-grid; }
60
+
58
61
  /* -- Indicator (hidden until first selection) -- */
59
62
  :scope > [data-indicator] {
60
63
  display: none;
@@ -107,7 +107,7 @@
107
107
  "default": false
108
108
  },
109
109
  "options": {
110
- "description": "Option list. Array of {value, label, disabled?} or grouped {label, options: [...]}. Alternative to declarative <option> / <optgroup> children.",
110
+ "description": "Option list. Array of {value, label, disabled?, icon?, avatar?} or grouped {label, options: [...]}. Alternative to declarative <option> / <optgroup> children. Per-option icon/avatar render in the list AND reflect in the trigger's selected state.",
111
111
  "type": "array",
112
112
  "default": []
113
113
  },
@@ -71,6 +71,7 @@ export class UISelect extends UIFormElement {
71
71
  static template = () => null;
72
72
 
73
73
  #options = [];
74
+ #ownTrigger = false; // true when WE stamped the trigger (vs a consumer-custom one)
74
75
  #listbox = null;
75
76
  #anchorCleanup = null;
76
77
  #query = '';
@@ -386,14 +387,18 @@ export class UISelect extends UIFormElement {
386
387
 
387
388
  // Stamp default trigger if none provided
388
389
  if (!this.querySelector('[slot="trigger"]')) {
390
+ this.#ownTrigger = true;
389
391
  // Detach listbox before innerHTML wipe so it isn't destroyed
390
392
  const lb = this.#listbox;
391
393
  if (lb?.parentNode === this) this.removeChild(lb);
392
394
 
395
+ // Initial leading reflects the host [avatar]/[icon]; #syncLeading() then
396
+ // reconciles it to the SELECTED option's icon/avatar on every render. The
397
+ // `data-select-leading` marker scopes that reconciliation to our element.
393
398
  const leading = this.avatar
394
- ? `<img slot="leading" src="${this.avatar}" alt="" />`
399
+ ? `<img slot="leading" data-select-leading src="${escapeHTML(this.avatar)}" alt="" />`
395
400
  : this.icon
396
- ? `<icon-ui slot="leading" name="${this.icon}"></icon-ui>`
401
+ ? `<icon-ui slot="leading" data-select-leading name="${escapeHTML(this.icon)}"></icon-ui>`
397
402
  : '';
398
403
  const displayMarkup = this.searchable
399
404
  ? `<input slot="display" type="text" role="combobox" aria-autocomplete="list" autocomplete="off" placeholder="${escapeHTML(this.placeholder || '')}" value="${escapeHTML(this.#displayText() === this.placeholder ? '' : this.#displayText())}" />`
@@ -470,6 +475,9 @@ export class UISelect extends UIFormElement {
470
475
  }
471
476
  }
472
477
 
478
+ // Reflect the selected option's icon/avatar in the trigger leading.
479
+ this.#syncLeading();
480
+
473
481
  // SPEC-040 — stamp / reconcile chips + "+N more" pill on every render.
474
482
  if (this.multiple) this.#stampChips();
475
483
  // Show clear-all only in multi-select mode when [clearable] + chips present.
@@ -548,12 +556,12 @@ export class UISelect extends UIFormElement {
548
556
  if (child.tagName === 'OPTGROUP') {
549
557
  const group = { label: child.label || child.getAttribute('label') || '', options: [] };
550
558
  for (const opt of child.querySelectorAll('option')) {
551
- group.options.push({ value: opt.value, label: opt.textContent.trim(), disabled: opt.disabled });
559
+ group.options.push({ value: opt.value, label: opt.textContent.trim(), disabled: opt.disabled, icon: opt.getAttribute('icon') || '', avatar: opt.getAttribute('avatar') || '' });
552
560
  if (opt.hasAttribute('selected')) preSelectedArr.push(opt.value);
553
561
  }
554
562
  this.#options.push(group);
555
563
  } else if (child.tagName === 'OPTION') {
556
- this.#options.push({ value: child.value, label: child.textContent.trim(), disabled: child.disabled });
564
+ this.#options.push({ value: child.value, label: child.textContent.trim(), disabled: child.disabled, icon: child.getAttribute('icon') || '', avatar: child.getAttribute('avatar') || '' });
557
565
  if (child.hasAttribute('selected')) preSelectedArr.push(child.value);
558
566
  } else if (
559
567
  // §225: skip [slot="display"] / [slot="listbox"] / [slot="action"] etc. — these are
@@ -614,6 +622,52 @@ export class UISelect extends UIFormElement {
614
622
 
615
623
  get options() { return this.#options; }
616
624
 
625
+ // Per-option leading markup: avatar (img) beats icon (icon-ui). Shared by
626
+ // the listbox rows AND the trigger (resolved against the selected option).
627
+ static #optionLeadHTML(opt) {
628
+ if (!opt) return '';
629
+ if (opt.avatar) return `<img data-option-avatar src="${escapeHTML(opt.avatar)}" alt="" />`;
630
+ if (opt.icon) return `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>`;
631
+ return '';
632
+ }
633
+
634
+ /**
635
+ * Reflect the SELECTED option's icon/avatar in the trigger's leading slot.
636
+ * Single-select only (multi-select shows chips). Falls back to the host
637
+ * [avatar]/[icon] when the selected option carries neither. Only manages the
638
+ * leading WE stamped (`[data-select-leading]`) — a consumer-custom trigger
639
+ * owns its own leading.
640
+ */
641
+ #syncLeading() {
642
+ if (this.multiple || !this.#ownTrigger) return;
643
+ const trigger = this.querySelector('[slot="trigger"]');
644
+ if (!trigger) return;
645
+ const flat = this.#options.flatMap((o) => o.options || [o]);
646
+ const sel = flat.find((o) => !o.header && !o.separator && o.value === this.value);
647
+ const avatar = (sel && sel.avatar) || this.avatar || '';
648
+ const icon = (sel && sel.icon) || this.icon || '';
649
+ const existing = trigger.querySelector(':scope > [data-select-leading]');
650
+ let html = '';
651
+ if (avatar) html = `<img slot="leading" data-select-leading src="${escapeHTML(avatar)}" alt="" />`;
652
+ else if (icon) html = `<icon-ui slot="leading" data-select-leading name="${escapeHTML(icon)}"></icon-ui>`;
653
+ if (!html) { existing?.remove(); return; }
654
+ const tmp = document.createElement('template');
655
+ tmp.innerHTML = html;
656
+ const next = tmp.content.firstElementChild;
657
+ if (!existing) {
658
+ trigger.insertBefore(next, trigger.firstChild);
659
+ } else if (existing.tagName === next.tagName) {
660
+ // Same element type — update the changing attribute in place.
661
+ if (next.tagName === 'IMG') {
662
+ if (existing.getAttribute('src') !== next.getAttribute('src')) existing.setAttribute('src', next.getAttribute('src'));
663
+ } else if (existing.getAttribute('name') !== next.getAttribute('name')) {
664
+ existing.setAttribute('name', next.getAttribute('name'));
665
+ }
666
+ } else {
667
+ existing.replaceWith(next); // icon ↔ avatar switch
668
+ }
669
+ }
670
+
617
671
  #renderOptions() {
618
672
  if (!this.#listbox) return;
619
673
  this.#listbox.innerHTML = '';
@@ -653,6 +707,8 @@ export class UISelect extends UIFormElement {
653
707
  // SPEC-040 — multi-select option rows render a leading checkbox
654
708
  // indicator (CSS-driven via [data-multi-option]); the `check` icon
655
709
  // shows when aria-selected="true".
710
+ // Per-option leading glyph — avatar (img) wins over icon (icon-ui).
711
+ const lead = UISelect.#optionLeadHTML(opt);
656
712
  if (this.multiple) {
657
713
  el.setAttribute('data-multi-option', '');
658
714
  const box = document.createElement('span');
@@ -661,11 +717,11 @@ export class UISelect extends UIFormElement {
661
717
  el.appendChild(box);
662
718
  const label = document.createElement('span');
663
719
  label.setAttribute('data-option-label', '');
664
- if (opt.icon) label.innerHTML = `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>${escapeHTML(opt.label)}`;
720
+ if (lead) label.innerHTML = `${lead}${escapeHTML(opt.label)}`;
665
721
  else label.textContent = opt.label;
666
722
  el.appendChild(label);
667
- } else if (opt.icon) {
668
- el.innerHTML = `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>${escapeHTML(opt.label)}`;
723
+ } else if (lead) {
724
+ el.innerHTML = `${lead}${escapeHTML(opt.label)}`;
669
725
  } else {
670
726
  el.textContent = opt.label;
671
727
  }
@@ -191,6 +191,11 @@
191
191
  :scope[data-multi-chips] [slot="trigger"] {
192
192
  flex-wrap: wrap;
193
193
  align-items: center;
194
+ /* Pack chips left with a small gap — override the base trigger's
195
+ `space-between` (which is for the single-select content↔caret split),
196
+ otherwise the chips spread across the full trigger width. The display
197
+ slot flex-grows (below) to push the caret to the trailing edge. */
198
+ justify-content: flex-start;
194
199
  gap: var(--a-space-1);
195
200
  /* min-height tracks single-row height when empty; flex-wrap allows
196
201
  it to grow as chips overflow. */
@@ -290,6 +295,8 @@ select-ui [slot="listbox"]:popover-open {
290
295
  }
291
296
 
292
297
  select-ui [role="option"] {
298
+ display: flex;
299
+ align-items: center;
293
300
  padding: var(--a-space-1) var(--a-ui-px);
294
301
  border-radius: var(--a-radius-sm);
295
302
  white-space: nowrap;
@@ -325,6 +332,17 @@ select-ui [role="option"] icon-ui {
325
332
  vertical-align: -0.125em;
326
333
  }
327
334
 
335
+ /* Option with avatar — small inline image (matches the trigger leading
336
+ avatar radius), sized to the option line-height. */
337
+ select-ui [role="option"] img[data-option-avatar] {
338
+ width: var(--a-ui-size);
339
+ height: var(--a-ui-size);
340
+ border-radius: var(--select-leading-radius, var(--select-leading-radius-default));
341
+ object-fit: cover;
342
+ margin-inline-end: var(--a-space-1);
343
+ vertical-align: -0.2em;
344
+ }
345
+
328
346
  /* Separator */
329
347
  select-ui [data-separator] {
330
348
  height: 1px;
@@ -23,7 +23,7 @@ description: |
23
23
  # Per ADR-0027 — primitives that programmatically create other primitives
24
24
  # do NOT auto-import them. Consumer (or demo shell) must explicitly import.
25
25
  composes:
26
- - icon-ui # chevron + option-row affixes (created in render)
26
+ - icon-ui # caret + option-row affixes (created in render)
27
27
  - tag-ui # multi-select chip per selected option in the trigger
28
28
  props:
29
29
  name:
@@ -151,7 +151,7 @@ props:
151
151
  default: false
152
152
  reflect: true
153
153
  options:
154
- description: "Option list. Array of {value, label, disabled?} or grouped {label, options: [...]}. Alternative to declarative <option> / <optgroup> children."
154
+ description: "Option list. Array of {value, label, disabled?, icon?, avatar?} or grouped {label, options: [...]}. Alternative to declarative <option> / <optgroup> children. Per-option icon/avatar render in the list AND reflect in the trigger's selected state."
155
155
  type: array
156
156
  default: []
157
157
  pattern:
@@ -249,6 +249,13 @@ a2ui:
249
249
  tag names are silently ignored (per §225 v0.5.9) and warned once
250
250
  at runtime. Or set `.options` programmatically as an array of
251
251
  `{value, label, disabled?}` (grouped form: `{label, options:[…]}`).
252
+ - >-
253
+ Per-option visuals: give each <option> an `icon` (Phosphor name) or
254
+ `avatar` (image URL) — `<option value="light" icon="sun">`. Each row
255
+ renders its glyph in the list AND the trigger reflects the SELECTED
256
+ option's icon/avatar (theme pickers, assignee/account switchers).
257
+ `avatar` wins over `icon`; a host-level [icon]/[avatar] is the
258
+ fallback when the selected option carries neither.
252
259
  - >-
253
260
  For dynamic option lists rendered inside <editor-shell>, set the
254
261
  JSON via the [data-options] attribute — <editor-shell>'s
@@ -14,8 +14,19 @@
14
14
  ],
15
15
  "properties": {
16
16
  "align": {
17
- "description": "Alignment of stacked items",
17
+ "description": "Alignment of the layered children within the shared cell (maps to grid place-items). `center` (default) plus eight directional keywords.",
18
18
  "type": "string",
19
+ "enum": [
20
+ "center",
21
+ "top-left",
22
+ "top",
23
+ "top-right",
24
+ "left",
25
+ "right",
26
+ "bottom-left",
27
+ "bottom",
28
+ "bottom-right"
29
+ ],
19
30
  "default": "center"
20
31
  },
21
32
  "component": {
@@ -21,6 +21,6 @@ cell.
21
21
  import { UIElement } from '../../core/element.js';
22
22
 
23
23
  export class UIStack extends UIElement {
24
- /** Alignment of stacked items */
25
- align: string;
24
+ /** Alignment of the layered children within the shared cell (maps to grid place-items). `center` (default) plus eight directional keywords. */
25
+ align: 'center' | 'top-left' | 'top' | 'top-right' | 'left' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right';
26
26
  }
@@ -18,9 +18,21 @@ description: |
18
18
  cell.
19
19
  props:
20
20
  align:
21
- description: Alignment of stacked items
21
+ description: >-
22
+ Alignment of the layered children within the shared cell (maps to
23
+ grid place-items). `center` (default) plus eight directional keywords.
22
24
  type: string
23
25
  default: center
26
+ enum:
27
+ - center
28
+ - top-left
29
+ - top
30
+ - top-right
31
+ - left
32
+ - right
33
+ - bottom-left
34
+ - bottom
35
+ - bottom-right
24
36
  events: {}
25
37
  slots:
26
38
  default:
@@ -13,6 +13,11 @@
13
13
  }
14
14
  ],
15
15
  "properties": {
16
+ "bleed": {
17
+ "description": "Horizontal-bleed KPI layout. With a `slot=\"chart\"` child, the value / label / change stack on the left while the chart fills the right column at full height and bleeds to the card's top / right / bottom edges (the horizontal counterpart to a chart in a card `<section bleed>`). No effect without a chart slot.",
18
+ "type": "boolean",
19
+ "default": false
20
+ },
16
21
  "change": {
17
22
  "description": "Change indicator text (e.g. '+12%', '-3%')",
18
23
  "type": "string",
@@ -72,6 +72,61 @@
72
72
  grid-column: 1;
73
73
  }
74
74
 
75
+ /* ── Horizontal-bleed KPI tile ──
76
+ value / label / change stack on the LEFT; the chart fills the RIGHT
77
+ column at full height and bleeds to the card's top / right / bottom
78
+ edges (cancels the card-section inset via negative margins keyed to
79
+ the inherited --card-inset). The compact `slot="chart"` reflow above
80
+ keeps the chart resting on the value baseline; this opt-in spreads it
81
+ into a full-height trajectory panel. Compose inside a card section:
82
+ <card-ui><section>
83
+ <stat-ui bleed value=… label=… change=… trend=…>
84
+ <chart-ui slot="chart" type="area" …></chart-ui>
85
+ </stat-ui>
86
+ </section></card-ui> */
87
+ :scope[bleed]:has([slot="chart"]) {
88
+ grid-template-columns: minmax(0, 1fr) minmax(0, var(--stat-bleed-col, 46%));
89
+ grid-template-areas:
90
+ "label chart"
91
+ "value chart"
92
+ "change chart";
93
+ align-items: start;
94
+ }
95
+ :scope[bleed] [slot="value"],
96
+ :scope[bleed] [slot="change"] {
97
+ grid-column: 1;
98
+ }
99
+ /* Icon shares the label row, pinned to the text column's inner edge. */
100
+ :scope[bleed] [slot="label"] {
101
+ padding-inline-end: var(--a-space-5);
102
+ }
103
+ :scope[bleed] [slot="icon"] {
104
+ grid-area: label;
105
+ justify-self: end;
106
+ align-self: start;
107
+ }
108
+ :scope[bleed] [slot="chart"] {
109
+ grid-area: chart;
110
+ /* VERTICAL bleed via stretch: a stretched grid item's height is
111
+ `track − margins`, so a negative margin-block overflows the track by
112
+ that amount top + bottom. `height:auto` overrides the base
113
+ chart-ui[slot="chart"]{height:100%} so the stretch governs. */
114
+ align-self: stretch;
115
+ height: auto;
116
+ margin-block: calc(-1 * var(--card-inset, var(--card-inset-default, 0px)));
117
+ /* HORIZONTAL bleed via explicit width: chart-ui pins its own
118
+ `width:100%` (chart.css), which beats justify-self:stretch and would
119
+ freeze the box to the track — so a negative margin-inline-end can't
120
+ widen it. Instead size the box to `track + inset` and left-anchor it,
121
+ so the right edge overflows to the card edge while the left keeps the
122
+ column gap. The percentage resolves against the grid track. */
123
+ justify-self: start;
124
+ width: calc(100% + var(--card-inset, var(--card-inset-default, 0px)));
125
+ min-width: 0;
126
+ /* override the inline-chart 4:3 — fill the row span instead */
127
+ aspect-ratio: auto;
128
+ }
129
+
75
130
  /* ── Label (eyebrow) ── */
76
131
  [slot="label"] {
77
132
  grid-area: label;
@@ -13,6 +13,8 @@
13
13
  import { UIElement } from '../../core/element.js';
14
14
 
15
15
  export class UIStat extends UIElement {
16
+ /** Horizontal-bleed KPI layout. With a `slot="chart"` child, the value / label / change stack on the left while the chart fills the right column at full height and bleeds to the card's top / right / bottom edges (the horizontal counterpart to a chart in a card `<section bleed>`). No effect without a chart slot. */
17
+ bleed: boolean;
16
18
  /** Change indicator text (e.g. '+12%', '-3%') */
17
19
  change: string;
18
20
  /** Icon name displayed in the icon slot */