@adia-ai/web-components 0.8.8 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.8.10] — 2026-07-20
4
+
5
+ ### Added
6
+ - **`<tag-ui>` gains an `accent` variant** (`components/tag/tag.{yaml,css}`; gh#372) — the brand-primary-colored option for non-semantic labels (filter chips, taxonomy tags) that shouldn't imply a success/warning/danger state, mirroring `<badge-ui>`'s existing `accent` variant token-for-token across all three `tone` values. `tag-ui`'s enum was `default | info | success | warning | danger`; `badge-ui`'s parallel enum already had `accent` — the two sibling components' variant vocabularies had drifted apart.
7
+
8
+ ### Fixed
9
+ - **`calendar-grid-ui` in-range highlight now extends across month boundaries** (`components/calendar-grid/calendar-grid.class.js`; gh#368) — a selected range spanning two months previously broke visual continuity at the boundary: the previous/next-month "filler" day loops rendered `data-outside` cells with no date comparison at all, so they could never carry `data-in-range`. Consolidated the per-day predicate logic into a shared `dayCell(date, day, outside)` closure used by all three day-loops; filler days stay exactly as unselectable as before — a pure visual-continuity fix, selection value unaffected.
10
+ - **Dialog-class backdrops (`modal-ui`, `drawer-ui`, `tour-ui`) read black at ~80% opacity, not mid-gray at 50%** (`styles/colors/semantics/{aliases,features}.css`; gh#373) — a self-documented regression from the 2026-07-14 "Material Phase 5" migration, which repointed the dialog scrim onto the shared neutral-500 static ladder (a mid-tone tint, never black; its darkest tier topped out at 60%). Added a purpose-built `--a-chrome-scrim-dialog: oklch(0 0 0 / 0.8)` token (matching the existing non-adaptive black/white CHROME-tier pattern) and repointed `--a-scrim-dialog` to it, leaving the shared ladder's other four roles (hover-wash, toast-veil, generic-overlay, heaviest-veil) untouched.
11
+ - **`<card-ui>`'s `width: stretch` no longer squashes unequal-content flex siblings to identical widths** (`components/card/card.css`; gh#380) — when several `card-ui` KPI tiles (e.g. stat-ui) sit as flex siblings without explicit sizing, `stretch` gave every card an identical flex-basis regardless of content, so `flex-shrink` divided the deficit equally — squeezing a long-title/large-value card to the same pixel width as a trivial short one, which then triggered `stat-ui`'s own (correct) ellipsis truncation. Changed to `flex: 1 1 auto`, which still lets a lone card fill its row but sizes flex-basis from content so unequal siblings shrink proportionally. No effect in block/grid contexts (this repo's canonical KPI-row pattern).
12
+ - **`table.yaml`'s `[raw]` a2ui rule no longer describes non-existent "demo seed data" behavior** (`components/table/table.yaml`; gh#385) — the rule dated to before v0.6.33 and described a data-lifecycle toggle that no longer exists; `[raw]` unconditionally short-circuits the entire data lifecycle (no header injection, no `.data`/`.columns` reconciliation, no aggregation/pagination footers), regardless of whether `.columns`/`.data` are set. Rewritten to match `render()`'s actual `if (this.raw) return;` early-return and every real consumer's usage (spreadsheet/inline-edit style patterns wrapping consumer-owned body markup).
13
+
14
+ ### Maintenance
15
+ - **`dist/` bundles rebuilt** (`web-components.{min.css,min.js,sheet.js}`, `host.{min.css,sheet.js}`, `theme-provider.min.js`) — regenerated from the source changes described above, not independent edits.
16
+
17
+ ## [0.8.9] — 2026-07-19
18
+
19
+ ### Added
20
+ - **`<select-ui mark>`** (gh#339) — a first-class `mark` boolean (host-level and per-option) renders `<adia-mark-ui>` (the token-driven Adia brand mark) as the leading visual, following the existing `icon`/`avatar` precedent exactly: `mark` wins over `avatar`, which wins over `icon`. Fits the workspace/app-switcher row that represents the Adia platform itself, where a static scheme-locked logo URL can't invert with the color scheme.
21
+
22
+ ### Fixed
23
+ - **`select-ui`'s `.options = [...]` setter now syncs the trigger's leading visual** — it previously only rebuilt the listbox rows (`#renderOptions()`), never reconciling the *trigger's* icon/avatar/mark to the selected option (`#syncLeading()`, only ever called from `render()`). Setting `.options` programmatically after connect — a documented, supported path — left the trigger stale even though the listbox rows were correct. Found and fixed while adding `mark` test coverage; affected the pre-existing `icon`/`avatar` props too, not just `mark`.
24
+
25
+ ### Maintenance
26
+ - **`components/` touched in this release window** (7 file(s), e.g. `select/select.a2ui.json`) — carried by the entries above.
27
+ - **`dist/` bundles rebuilt** in this cut's window (1 file(s)) — regenerated from the source changes described above, not independent edits.
28
+ - **`patterns/` touched in this release window** (2 file(s), e.g. `permissions-matrix/permissions-matrix.examples.html`) — carried by the entries above.
29
+
3
30
  ## [0.8.8] — 2026-07-19
4
31
 
5
32
  ### Changed
@@ -181,36 +181,39 @@ export class UICalendarGrid extends UIElement {
181
181
  const daysInMonth = new Date(year, month + 1, 0).getDate();
182
182
  const daysInPrev = new Date(year, month, 0).getDate();
183
183
 
184
- h += '<div data-cal-grid>';
185
-
186
- // Previous-month trailing days (outside, disabled)
187
- for (let i = firstDay - 1; i >= 0; i--) {
188
- const day = daysInPrev - i;
189
- h += `<button-ui variant="ghost" data-cal-day data-outside disabled tabindex="-1" text="${day}"></button-ui>`;
190
- }
191
-
192
- // Current-month days
193
- for (let d = 1; d <= daysInMonth; d++) {
194
- const date = new Date(year, month, d);
195
- const iso = toISO(year, month, d);
196
- const isToday = sameDay(date, today);
197
- const isSelected = selected && sameDay(date, selected);
198
- const isDisabled = (minDate && date < minDate) || (maxDate && date > maxDate);
199
- const isFocused = this.#focusedDay === d;
184
+ // <button-ui variant="ghost" text="…"> (gh issue 276 wave 4) — the day
185
+ // number moves from element text content to the `text` attribute, which
186
+ // button-ui renders via CSS attr(text) on its ::after.
187
+ //
188
+ // Shared by all three day-loops (prev-month filler / current-month /
189
+ // next-month filler) so today/selected/in-range predicates stay
190
+ // consistent across a month boundary (gh#368) — a range spanning two
191
+ // months must show a continuous in-range band through the filler days
192
+ // representing the adjacent month, not just within each month's own
193
+ // cells. Filler days pass `outside: true`, which forces them
194
+ // disabled/unfocusable/unclickable regardless of the computed
195
+ // predicates this only affects the highlight band's visual
196
+ // continuity, never selectability.
197
+ const dayCell = (date, day, outside) => {
198
+ const iso = toISO(date.getFullYear(), date.getMonth(), date.getDate());
199
+ const isToday = sameDay(date, today);
200
+ const isSelected = !!(selected && sameDay(date, selected));
200
201
  // In-range: strictly between rangeStart and rangeEnd (endpoints
201
202
  // themselves render as `[data-selected]` via the `value` prop;
202
203
  // marking them in-range too would double-up the styling).
203
- const isInRange = !!(rangeFrom && rangeTo
204
+ const isInRange = !!(rangeFrom && rangeTo
204
205
  && date > rangeFrom && date < rangeTo);
205
206
  // Range endpoint flags — let CSS render the start/end cell with
206
- // half-pill caps so the in-range strip reads as a continuous
207
- // band when displayed next to the in-range cells.
207
+ // half-pill caps so the in-range strip reads as a continuous band
208
+ // when displayed next to the in-range cells.
208
209
  const isRangeStart = !!(rangeFrom && sameDay(date, rangeFrom));
209
210
  const isRangeEnd = !!(rangeTo && sameDay(date, rangeTo));
211
+ const isDisabled = outside || (minDate && date < minDate) || (maxDate && date > maxDate);
212
+ const isFocused = !outside && this.#focusedDay === day;
210
213
 
211
214
  const attrs = [
212
215
  'data-cal-day',
213
- `data-date="${iso}"`,
216
+ outside ? 'data-outside' : `data-date="${iso}"`,
214
217
  isToday ? 'data-today' : '',
215
218
  isSelected ? 'data-selected' : '',
216
219
  isInRange ? 'data-in-range' : '',
@@ -221,17 +224,31 @@ export class UICalendarGrid extends UIElement {
221
224
  `tabindex="${isFocused ? '0' : '-1'}"`,
222
225
  ].filter(Boolean).join(' ');
223
226
 
224
- // <button-ui variant="ghost" text=""> (gh issue 276 wave 4) — the day
225
- // number moves from element text content to the `text` attribute,
226
- // which button-ui renders via CSS attr(text) on its ::after.
227
- h += `<button-ui variant="ghost" ${attrs} text="${d}"></button-ui>`;
227
+ return `<button-ui variant="ghost" ${attrs} text="${day}"></button-ui>`;
228
+ };
229
+
230
+ h += '<div data-cal-grid>';
231
+
232
+ // Previous-month trailing days (outside, disabled)
233
+ const prevMonth = month === 0 ? 11 : month - 1;
234
+ const prevYear = month === 0 ? year - 1 : year;
235
+ for (let i = firstDay - 1; i >= 0; i--) {
236
+ const day = daysInPrev - i;
237
+ h += dayCell(new Date(prevYear, prevMonth, day), day, true);
238
+ }
239
+
240
+ // Current-month days
241
+ for (let d = 1; d <= daysInMonth; d++) {
242
+ h += dayCell(new Date(year, month, d), d, false);
228
243
  }
229
244
 
230
245
  // Next-month leading days
231
246
  const totalCells = firstDay + daysInMonth;
232
247
  const remaining = (7 - (totalCells % 7)) % 7;
248
+ const nextMonth = month === 11 ? 0 : month + 1;
249
+ const nextYear = month === 11 ? year + 1 : year;
233
250
  for (let d = 1; d <= remaining; d++) {
234
- h += `<button-ui variant="ghost" data-cal-day data-outside disabled tabindex="-1" text="${d}"></button-ui>`;
251
+ h += dayCell(new Date(nextYear, nextMonth, d), d, true);
235
252
  }
236
253
 
237
254
  h += '</div>';
@@ -61,7 +61,7 @@
61
61
  box-shadow: var(--card-shadow);
62
62
  overflow: hidden;
63
63
  corner-shape: superellipse(1.1);
64
- width: stretch;
64
+ flex: 1 1 auto;
65
65
  }
66
66
 
67
67
  /* ═══════ Variants — token-only overrides ═══════ */
@@ -66,6 +66,11 @@
66
66
  "type": "string",
67
67
  "default": ""
68
68
  },
69
+ "mark": {
70
+ "description": "Renders the Adia brand mark (`<adia-mark-ui>`) as the leading visual — takes precedence over avatar and icon. Token-driven (light/dark handled internally), so it fits a scheme-switching workspace/app switcher where a static logo URL can't invert. Per-option `mark` on an `<option>`/options-array entry works the same way, scoped to that row.",
71
+ "type": "boolean",
72
+ "default": false
73
+ },
69
74
  "max": {
70
75
  "description": "Multi-select only. Maximum allowed selections. Toggling past the\ncap is suppressed; the `invalid` event fires with reason=\"max\".\n`0` (default) = unlimited.\n",
71
76
  "type": "number",
@@ -107,7 +112,7 @@
107
112
  "default": false
108
113
  },
109
114
  "options": {
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.",
115
+ "description": "Option list. Array of {value, label, disabled?, icon?, avatar?, mark?} or grouped {label, options: [...]}. Alternative to declarative <option> / <optgroup> children. Per-option icon/avatar/mark render in the list AND reflect in the trigger's selected state (mark takes precedence over avatar, which takes precedence over icon).",
111
116
  "$ref": "common_types.json#/$defs/DynamicStringList"
112
117
  },
113
118
  "pattern": {
@@ -48,6 +48,7 @@ export class UISelect extends UIFormElement {
48
48
  label: { type: String, default: '', reflect: true },
49
49
  icon: { type: String, default: '', reflect: true },
50
50
  avatar: { type: String, default: '', reflect: true },
51
+ mark: { type: Boolean, default: false, reflect: true },
51
52
  multiple: { type: Boolean, default: false, reflect: true },
52
53
  searchable: { type: Boolean, default: false, reflect: true },
53
54
  freeText: { type: Boolean, default: false, reflect: true, attribute: 'free-text' },
@@ -417,14 +418,17 @@ export class UISelect extends UIFormElement {
417
418
  const lb = this.#listbox;
418
419
  if (lb?.parentNode === this) this.removeChild(lb);
419
420
 
420
- // Initial leading reflects the host [avatar]/[icon]; #syncLeading() then
421
- // reconciles it to the SELECTED option's icon/avatar on every render. The
422
- // `data-select-leading` marker scopes that reconciliation to our element.
423
- const leading = this.avatar
424
- ? `<img slot="leading" data-select-leading src="${escapeHTML(this.avatar)}" alt="" />`
425
- : this.icon
426
- ? `<icon-ui slot="leading" data-select-leading name="${escapeHTML(this.icon)}"></icon-ui>`
427
- : '';
421
+ // Initial leading reflects the host [mark]/[avatar]/[icon]; #syncLeading()
422
+ // then reconciles it to the SELECTED option's mark/avatar/icon on every
423
+ // render. The `data-select-leading` marker scopes that reconciliation to
424
+ // our element. mark wins over avatar, which wins over icon (gh#339).
425
+ const leading = this.mark
426
+ ? `<adia-mark-ui slot="leading" data-select-leading size="xs"></adia-mark-ui>`
427
+ : this.avatar
428
+ ? `<img slot="leading" data-select-leading src="${escapeHTML(this.avatar)}" alt="" />`
429
+ : this.icon
430
+ ? `<icon-ui slot="leading" data-select-leading name="${escapeHTML(this.icon)}"></icon-ui>`
431
+ : '';
428
432
  const displayMarkup = this.searchable
429
433
  ? `<input slot="display" type="text" role="combobox" aria-autocomplete="list" autocomplete="off" placeholder="${escapeHTML(this.placeholder || '')}" value="${escapeHTML(this.#displayText() === this.placeholder ? '' : this.#displayText())}" />`
430
434
  : `<span slot="display">${escapeHTML(this.#displayText())}</span>`;
@@ -498,7 +502,7 @@ export class UISelect extends UIFormElement {
498
502
  }
499
503
  }
500
504
 
501
- // Reflect the selected option's icon/avatar in the trigger leading.
505
+ // Reflect the selected option's mark/avatar/icon in the trigger leading.
502
506
  this.#syncLeading();
503
507
 
504
508
  // SPEC-040 — stamp / reconcile chips + "+N more" pill on every render.
@@ -599,12 +603,12 @@ export class UISelect extends UIFormElement {
599
603
  if (child.tagName === 'OPTGROUP') {
600
604
  const group = { label: child.label || child.getAttribute('label') || '', options: [] };
601
605
  for (const opt of child.querySelectorAll('option')) {
602
- group.options.push({ value: opt.value, label: opt.textContent.trim(), disabled: opt.disabled, icon: opt.getAttribute('icon') || '', avatar: opt.getAttribute('avatar') || '' });
606
+ group.options.push({ value: opt.value, label: opt.textContent.trim(), disabled: opt.disabled, icon: opt.getAttribute('icon') || '', avatar: opt.getAttribute('avatar') || '', mark: opt.hasAttribute('mark') });
603
607
  if (opt.hasAttribute('selected')) preSelectedArr.push(opt.value);
604
608
  }
605
609
  this.#options.push(group);
606
610
  } else if (child.tagName === 'OPTION') {
607
- this.#options.push({ value: child.value, label: child.textContent.trim(), disabled: child.disabled, icon: child.getAttribute('icon') || '', avatar: child.getAttribute('avatar') || '' });
611
+ this.#options.push({ value: child.value, label: child.textContent.trim(), disabled: child.disabled, icon: child.getAttribute('icon') || '', avatar: child.getAttribute('avatar') || '', mark: child.hasAttribute('mark') });
608
612
  if (child.hasAttribute('selected')) preSelectedArr.push(child.value);
609
613
  } else if (
610
614
  // §225: skip [slot="display"] / [slot="listbox"] / [slot="action"] etc. — these are
@@ -660,26 +664,38 @@ export class UISelect extends UIFormElement {
660
664
  this.#renderOptions();
661
665
  const display = this.querySelector('[slot="display"]');
662
666
  if (display) display.textContent = this.#displayText();
667
+ // Pre-existing gap surfaced while adding mark (gh#339): #renderOptions()
668
+ // only rebuilds the LISTBOX rows — the trigger's leading visual
669
+ // (icon/avatar/mark) was only ever reconciled from render()'s call to
670
+ // #syncLeading(), never from this setter. So setting `.options = [...]`
671
+ // programmatically after connect (a documented, supported path) left a
672
+ // stale or missing leading on the trigger even though the listbox rows
673
+ // were correct. #syncLeading() already self-guards (multi-select /
674
+ // consumer-custom-trigger), so calling it here is safe.
675
+ this.#syncLeading();
663
676
  });
664
677
  }
665
678
 
666
679
  get options() { return this.#options; }
667
680
 
668
- // Per-option leading markup: avatar (img) beats icon (icon-ui). Shared by
669
- // the listbox rows AND the trigger (resolved against the selected option).
681
+ // Per-option leading markup: mark (adia-mark-ui) beats avatar (img) beats
682
+ // icon (icon-ui) — gh#339. Shared by the listbox rows AND the trigger
683
+ // (resolved against the selected option).
670
684
  static #optionLeadHTML(opt) {
671
685
  if (!opt) return '';
686
+ if (opt.mark) return `<adia-mark-ui size="xs"></adia-mark-ui>`;
672
687
  if (opt.avatar) return `<img data-option-avatar src="${escapeHTML(opt.avatar)}" alt="" />`;
673
688
  if (opt.icon) return `<icon-ui name="${escapeHTML(opt.icon)}"></icon-ui>`;
674
689
  return '';
675
690
  }
676
691
 
677
692
  /**
678
- * Reflect the SELECTED option's icon/avatar in the trigger's leading slot.
679
- * Single-select only (multi-select shows chips). Falls back to the host
680
- * [avatar]/[icon] when the selected option carries neither. Only manages the
681
- * leading WE stamped (`[data-select-leading]`)a consumer-custom trigger
682
- * owns its own leading.
693
+ * Reflect the SELECTED option's mark/avatar/icon in the trigger's leading
694
+ * slot. Single-select only (multi-select shows chips). Falls back to the
695
+ * host [mark]/[avatar]/[icon] when the selected option carries none of the
696
+ * three (mark wins over avatar, which wins over icon gh#339). Only
697
+ * manages the leading WE stamped (`[data-select-leading]`) — a
698
+ * consumer-custom trigger owns its own leading.
683
699
  */
684
700
  #syncLeading() {
685
701
  if (this.multiple || !this.#ownTrigger) return;
@@ -687,11 +703,13 @@ export class UISelect extends UIFormElement {
687
703
  if (!trigger) return;
688
704
  const flat = this.#options.flatMap((o) => o.options || [o]);
689
705
  const sel = flat.find((o) => !o.header && !o.separator && o.value === this.value);
706
+ const mark = (sel && sel.mark) || this.mark || false;
690
707
  const avatar = (sel && sel.avatar) || this.avatar || '';
691
708
  const icon = (sel && sel.icon) || this.icon || '';
692
709
  const existing = trigger.querySelector(':scope > [data-select-leading]');
693
710
  let html = '';
694
- if (avatar) html = `<img slot="leading" data-select-leading src="${escapeHTML(avatar)}" alt="" />`;
711
+ if (mark) html = `<adia-mark-ui slot="leading" data-select-leading size="xs"></adia-mark-ui>`;
712
+ else if (avatar) html = `<img slot="leading" data-select-leading src="${escapeHTML(avatar)}" alt="" />`;
695
713
  else if (icon) html = `<icon-ui slot="leading" data-select-leading name="${escapeHTML(icon)}"></icon-ui>`;
696
714
  if (!html) { existing?.remove(); return; }
697
715
  const tmp = document.createElement('template');
@@ -751,7 +769,8 @@ export class UISelect extends UIFormElement {
751
769
  // SPEC-040 — multi-select option rows render a leading checkbox
752
770
  // indicator (CSS-driven via [data-multi-option]); the `check` icon
753
771
  // shows when aria-selected="true".
754
- // Per-option leading glyph — avatar (img) wins over icon (icon-ui).
772
+ // Per-option leading glyph — mark (adia-mark-ui) wins over avatar
773
+ // (img), which wins over icon (icon-ui).
755
774
  const lead = UISelect.#optionLeadHTML(opt);
756
775
  if (this.multiple) {
757
776
  el.setAttribute('data-multi-option', '');
@@ -11,6 +11,8 @@ export interface SelectOption {
11
11
  label?: string;
12
12
  icon?: string;
13
13
  avatar?: string;
14
+ /** Renders the Adia brand mark as this option's leading visual. Takes precedence over avatar/icon. */
15
+ mark?: boolean;
14
16
  disabled?: boolean;
15
17
  action?: string;
16
18
  divider?: boolean;
@@ -47,6 +49,8 @@ export class UISelect extends UIFormElement {
47
49
  icon: string;
48
50
  /** Leading avatar URL or name. */
49
51
  avatar: string;
52
+ /** Renders the Adia brand mark as the leading visual — takes precedence over avatar and icon (gh#339). */
53
+ mark: boolean;
50
54
  multiple: boolean;
51
55
  searchable: boolean;
52
56
  /** Allow values not in the option list (combobox mode). */
@@ -13,37 +13,26 @@
13
13
  </field-ui>
14
14
  ```
15
15
 
16
- ## size
16
+ ## variant
17
17
 
18
18
  ```html
19
- <field-ui label="Small">
20
- <select-ui size="sm" placeholder="Pick a fruit">
21
- <option value="apple">Apple</option>
22
- <option value="banana">Banana</option>
23
- <option value="cherry">Cherry</option>
19
+ <field-ui label="Sort by">
20
+ <select-ui variant="ghost" value="recent">
21
+ <option value="recent">Most recent</option>
22
+ <option value="popular">Most popular</option>
23
+ <option value="name">Name (A–Z)</option>
24
24
  </select-ui>
25
25
  </field-ui>
26
26
  ```
27
27
 
28
- ## grouped options
28
+ ## size
29
29
 
30
30
  ```html
31
- <field-ui label="Location">
32
- <select-ui placeholder="Choose a city">
33
- <optgroup label="North America">
34
- <option value="nyc">New York</option>
35
- <option value="sf">San Francisco</option>
36
- <option value="toronto">Toronto</option>
37
- </optgroup>
38
- <optgroup label="Europe">
39
- <option value="london">London</option>
40
- <option value="paris">Paris</option>
41
- <option value="berlin">Berlin</option>
42
- </optgroup>
43
- <optgroup label="Asia">
44
- <option value="tokyo">Tokyo</option>
45
- <option value="singapore">Singapore</option>
46
- </optgroup>
31
+ <field-ui label="Small">
32
+ <select-ui size="sm" placeholder="Pick a fruit">
33
+ <option value="apple">Apple</option>
34
+ <option value="banana">Banana</option>
35
+ <option value="cherry">Cherry</option>
47
36
  </select-ui>
48
37
  </field-ui>
49
38
  ```
@@ -359,4 +359,95 @@ describe('select-ui', () => {
359
359
  expect(s.hasAttribute('aria-label')).toBe(false);
360
360
  });
361
361
  });
362
+
363
+ // gh#339 — mark (adia-mark-ui) as a leading visual, alongside icon/avatar.
364
+ describe('mark (gh#339)', () => {
365
+ it('renders <adia-mark-ui> in the listbox row for a declarative <option mark>', async () => {
366
+ const s = mount(`
367
+ <select-ui placeholder="Choose…">
368
+ <option value="adia" mark>Adia</option>
369
+ <option value="acme" avatar="https://example.test/acme.png">Acme</option>
370
+ </select-ui>
371
+ `);
372
+ await tick();
373
+ const row = s.querySelector('[role="option"][data-value="adia"]');
374
+ expect(row.querySelector('adia-mark-ui')).not.toBeNull();
375
+ expect(s.options.find((o) => o.value === 'adia').mark).toBe(true);
376
+ });
377
+
378
+ it('reflects the SELECTED option\'s mark in the trigger leading slot', async () => {
379
+ const s = mount(`
380
+ <select-ui value="adia">
381
+ <option value="adia" mark>Adia</option>
382
+ <option value="acme" avatar="https://example.test/acme.png">Acme</option>
383
+ </select-ui>
384
+ `);
385
+ await tick();
386
+ const leading = s.querySelector('[slot="trigger"] > [data-select-leading]');
387
+ expect(leading.tagName).toBe('ADIA-MARK-UI');
388
+ });
389
+
390
+ it('mark wins over avatar and icon when more than one is set on the same option', async () => {
391
+ const s = mount(`<select-ui value="a"></select-ui>`);
392
+ await tick();
393
+ s.options = [{ value: 'a', label: 'A', mark: true, avatar: 'https://example.test/a.png', icon: 'star' }];
394
+ await tick();
395
+ const leading = s.querySelector('[slot="trigger"] > [data-select-leading]');
396
+ expect(leading.tagName).toBe('ADIA-MARK-UI');
397
+ });
398
+
399
+ it('switching the SELECTED option from mark to avatar swaps the trigger leading element (not just its attributes)', async () => {
400
+ const s = mount(`
401
+ <select-ui value="adia">
402
+ <option value="adia" mark>Adia</option>
403
+ <option value="acme" avatar="https://example.test/acme.png">Acme</option>
404
+ </select-ui>
405
+ `);
406
+ await tick();
407
+ expect(s.querySelector('[slot="trigger"] > [data-select-leading]').tagName).toBe('ADIA-MARK-UI');
408
+ s.value = 'acme';
409
+ await tick();
410
+ const leading = s.querySelector('[slot="trigger"] > [data-select-leading]');
411
+ expect(leading.tagName).toBe('IMG');
412
+ expect(leading.getAttribute('src')).toBe('https://example.test/acme.png');
413
+ });
414
+
415
+ it('a host-level [mark] renders the brand mark as a static leading visual when no option overrides it', async () => {
416
+ const s = mount(`
417
+ <select-ui mark value="adia">
418
+ <option value="adia">Adia</option>
419
+ </select-ui>
420
+ `);
421
+ await tick();
422
+ const leading = s.querySelector('[slot="trigger"] > [data-select-leading]');
423
+ expect(leading.tagName).toBe('ADIA-MARK-UI');
424
+ });
425
+
426
+ it('renders <adia-mark-ui> in a listbox ROW under [multiple] mode too (shares #optionLeadHTML with single-select)', async () => {
427
+ const s = mount(`
428
+ <select-ui multiple value="adia">
429
+ <option value="adia" mark>Adia</option>
430
+ <option value="acme" avatar="https://example.test/acme.png">Acme</option>
431
+ </select-ui>
432
+ `);
433
+ await tick();
434
+ const row = s.querySelector('[role="option"][data-value="adia"]');
435
+ expect(row.querySelector('adia-mark-ui')).not.toBeNull();
436
+ // Multi-select shows chips, not a leading visual, in the TRIGGER — mark
437
+ // is a correct no-op there (#syncLeading's `if (this.multiple) return`).
438
+ expect(s.querySelector('[slot="trigger"] > [data-select-leading]')).toBeNull();
439
+ });
440
+
441
+ it('mark set via the .options property (not just the declarative attribute) is honoured', async () => {
442
+ const s = mount(`<select-ui value="adia"></select-ui>`);
443
+ await tick();
444
+ s.options = [
445
+ { value: 'adia', label: 'Adia', mark: true },
446
+ { value: 'acme', label: 'Acme', avatar: 'https://example.test/acme.png' },
447
+ ];
448
+ await tick();
449
+ const leading = s.querySelector('[slot="trigger"] > [data-select-leading]');
450
+ expect(leading.tagName).toBe('ADIA-MARK-UI');
451
+ });
452
+ });
362
453
  });
@@ -56,6 +56,11 @@ props:
56
56
  description: URL for leading avatar image (takes precedence over icon)
57
57
  type: string
58
58
  default: ""
59
+ mark:
60
+ description: Renders the Adia brand mark (`<adia-mark-ui>`) as the leading visual — takes precedence over avatar and icon. Token-driven (light/dark handled internally), so it fits a scheme-switching workspace/app switcher where a static logo URL can't invert. Per-option `mark` on an `<option>`/options-array entry works the same way, scoped to that row.
61
+ type: boolean
62
+ default: false
63
+ reflect: true
59
64
  disabled:
60
65
  description: Disables interaction and dims the control
61
66
  type: boolean
@@ -153,7 +158,7 @@ props:
153
158
  default: false
154
159
  reflect: true
155
160
  options:
156
- 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."
161
+ description: "Option list. Array of {value, label, disabled?, icon?, avatar?, mark?} or grouped {label, options: [...]}. Alternative to declarative <option> / <optgroup> children. Per-option icon/avatar/mark render in the list AND reflect in the trigger's selected state (mark takes precedence over avatar, which takes precedence over icon)."
157
162
  type: array
158
163
  default: []
159
164
  dynamic: true # JS-set collection prop with a custom setter — deliberately not in static properties
@@ -253,12 +258,16 @@ a2ui:
253
258
  at runtime. Or set `.options` programmatically as an array of
254
259
  `{value, label, disabled?}` (grouped form: `{label, options:[…]}`).
255
260
  - >-
256
- Per-option visuals: give each <option> an `icon` (Phosphor name) or
257
- `avatar` (image URL) `<option value="light" icon="sun">`. Each row
258
- renders its glyph in the list AND the trigger reflects the SELECTED
259
- option's icon/avatar (theme pickers, assignee/account switchers).
260
- `avatar` wins over `icon`; a host-level [icon]/[avatar] is the
261
- fallback when the selected option carries neither.
261
+ Per-option visuals: give each <option> an `icon` (Phosphor name),
262
+ `avatar` (image URL), or `mark` (Adia brand mark)
263
+ `<option value="light" icon="sun">`. Each row renders its glyph in
264
+ the list AND the trigger reflects the SELECTED option's icon/avatar/mark
265
+ (theme pickers, assignee/account switchers, workspace switchers).
266
+ `mark` wins over `avatar`, which wins over `icon`; a host-level
267
+ [mark]/[icon]/[avatar] is the fallback when the selected option
268
+ carries none of the three. `mark` renders `<adia-mark-ui>` — a
269
+ token-driven brand mark that inverts with the color scheme, so a
270
+ workspace/app switcher doesn't need a static, scheme-locked logo URL.
262
271
  - >-
263
272
  For dynamic option lists rendered inside <editor-shell>, set the
264
273
  JSON via the [data-options] attribute — <editor-shell>'s
@@ -269,9 +269,15 @@ a2ui:
269
269
  multiline, or per-cell with [data-wrap] on a single column /
270
270
  cell.
271
271
  - >-
272
- Use [raw] in production app consumers it disables the demo
273
- seed data so the table renders only consumer-provided rows /
274
- columns / data props.
272
+ [raw] is a visual-and-lifecycle chrome reset for consumer-owned
273
+ body markup render()'s early return (`if (this.raw) return;`)
274
+ is unconditional: no header injection, no .data/.columns
275
+ reconciliation, no empty/loading overlays, no aggregation or
276
+ pagination footers, even if .columns and .data are set. Reach
277
+ for it only when the consumer authors 100% of the body
278
+ (spreadsheet / inline-edit style patterns wrapping a native
279
+ <table>) — never to "keep the data lifecycle but skip demo
280
+ seeding."
275
281
  - >-
276
282
  Listen for the `sort` event with detail.key + detail.dir (NOT
277
283
  .column / .direction). `cell-click` detail carries {key, row,
@@ -55,10 +55,11 @@
55
55
  "default": "solid"
56
56
  },
57
57
  "variant": {
58
- "description": "Semantic variant — `default | info | success | warning | danger`.",
58
+ "description": "Semantic variant — `default | accent | info | success | warning | danger`. `accent` is brand-primary emphasis for non-semantic labels — a filter chip or taxonomy tag that shouldn't imply a success/warning/danger state. Mirrors <badge-ui>'s `accent` variant token-for-token.",
59
59
  "type": "string",
60
60
  "enum": [
61
61
  "default",
62
+ "accent",
62
63
  "info",
63
64
  "success",
64
65
  "warning",
@@ -93,6 +93,15 @@ tag-ui[removable]:not([disabled]):hover {
93
93
  text, near-white in both schemes by design). Reads as a status pill
94
94
  where the chip IS the state. Opt out per-tag via [tone="muted"] for
95
95
  metadata-chip surfaces in dense lists. */
96
+ /* `accent` is brand-primary emphasis for non-semantic labels — mirrors
97
+ <badge-ui>'s `accent` variant token-for-token (--a-primary-bg /
98
+ on-primary for the solid fill), applied to tag-ui's own solid-default
99
+ tone shape. Not a status color — no success/warning/danger implication. */
100
+ :scope[variant="accent"] {
101
+ --tag-bg: var(--a-primary-bg);
102
+ --tag-fg: var(--md-sys-color-primary-on-primary);
103
+ }
104
+
96
105
  :scope[variant="info"] {
97
106
  --tag-bg: var(--a-info-bg);
98
107
  --tag-fg: var(--md-sys-color-info-on-info);
@@ -149,11 +158,21 @@ tag-ui[removable]:not([disabled]):hover {
149
158
  --tag-bg: var(--md-sys-color-danger-container);
150
159
  --tag-fg: var(--md-sys-color-danger-on-surface);
151
160
  }
161
+ /* Mirrors <badge-ui>'s default (muted) accent pair —
162
+ --md-sys-color-primary-container / -on-surface. */
163
+ :scope[tone="muted"][variant="accent"] {
164
+ --tag-bg: var(--md-sys-color-primary-container);
165
+ --tag-fg: var(--md-sys-color-primary-on-surface);
166
+ }
152
167
 
153
168
  /* `[tone="solid"]` on the neutral default (or no variant) inverts the
154
169
  chrome — solid fg-color bg with bg-color text. High-contrast neutral
155
- stamp. Explicit opt-in; the variant-less default stays quiet chrome. */
156
- :scope[tone="solid"]:not([variant="info"]):not([variant="success"]):not([variant="warning"]):not([variant="danger"]) {
170
+ stamp. Explicit opt-in; the variant-less default stays quiet chrome.
171
+ [variant="accent"] is excluded — it already has an explicit solid
172
+ pair above (the plain [variant="accent"] rule) that this higher-
173
+ specificity fallback would otherwise clobber when tone="solid" is
174
+ set explicitly alongside it. */
175
+ :scope[tone="solid"]:not([variant="info"]):not([variant="success"]):not([variant="warning"]):not([variant="danger"]):not([variant="accent"]) {
157
176
  --tag-bg: var(--md-sys-color-neutral-on-surface);
158
177
  --tag-fg: var(--md-sys-color-neutral-background);
159
178
  }
@@ -182,8 +201,15 @@ tag-ui[removable]:not([disabled]):hover {
182
201
  --tag-fg: var(--md-sys-color-danger-on-surface);
183
202
  --tag-border: var(--md-sys-color-danger-outline);
184
203
  }
185
- /* Outline on neutral (no family) fg-muted text + subtle border. */
186
- :scope[tone="outline"]:not([variant="info"]):not([variant="success"]):not([variant="warning"]):not([variant="danger"]) {
204
+ /* Mirrors <badge-ui>'s outline accent pair
205
+ --md-sys-color-primary-on-surface / -primary-outline. */
206
+ :scope[tone="outline"][variant="accent"] {
207
+ --tag-fg: var(--md-sys-color-primary-on-surface);
208
+ --tag-border: var(--md-sys-color-primary-outline);
209
+ }
210
+ /* Outline on neutral (no family) — fg-muted text + subtle border.
211
+ [variant="accent"] excluded — see the [tone="solid"] fallback note above. */
212
+ :scope[tone="outline"]:not([variant="info"]):not([variant="success"]):not([variant="warning"]):not([variant="danger"]):not([variant="accent"]) {
187
213
  --tag-fg: var(--a-fg-muted);
188
214
  --tag-border: var(--md-sys-color-neutral-outline);
189
215
  }
@@ -54,8 +54,8 @@ tone unless `tone="solid"` is set explicitly (high-contrast neutral
54
54
  inverse), or `tone="outline"` (fg-muted text + subtle border).
55
55
  */
56
56
  tone: 'solid' | 'muted' | 'outline';
57
- /** Semantic variant — `default | info | success | warning | danger`. */
58
- variant: 'default' | 'info' | 'success' | 'warning' | 'danger';
57
+ /** Semantic variant — `default | accent | info | success | warning | danger`. `accent` is brand-primary emphasis for non-semantic labels — a filter chip or taxonomy tag that shouldn't imply a success/warning/danger state. Mirrors <badge-ui>'s `accent` variant token-for-token. */
58
+ variant: 'default' | 'accent' | 'info' | 'success' | 'warning' | 'danger';
59
59
 
60
60
  addEventListener<K extends keyof HTMLElementEventMap>(
61
61
  type: K,