@adia-ai/web-components 0.6.16 → 0.6.18

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,95 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.6.18] — 2026-05-21
4
+
5
+ ### Added — `[data-scheme]` attribute on `toggle-scheme-ui` (FB-14)
6
+
7
+ - **`toggle-scheme-ui` now writes the `[data-scheme]` attribute** on the target
8
+ element alongside the existing `color-scheme` inline style. `styles/themes.css`
9
+ selects on `[data-scheme="light|dark|system"]`; consumer CSS patterned after
10
+ it never fired because the component wrote only the inline style.
11
+ `#writeTarget()` sets the attribute, `#clearTargetOverride()` removes it —
12
+ paired with the style so the two never diverge. Non-breaking: consumers
13
+ relying on the `color-scheme` style alone are unaffected. (~6 LOC `class.js`.)
14
+
15
+ ### Added — `button-ui` text/icon symbol-duplication warning (FB-15)
16
+
17
+ - **`button-ui` emits a one-shot `console.warn`** when `text=` begins with a
18
+ symbol that `icon=` already renders — e.g. `text="+ New Item" icon="plus"`
19
+ renders a doubled `+`. Covers the high-frequency `plus` / `minus` / `x` /
20
+ `check` / `arrow-*` collisions. Warning-only (WeakSet-guarded, GC-friendly);
21
+ the button still renders and `text=` is never mutated. `button.yaml` gains the
22
+ matching `anti_patterns` entry so A2UI validators flag it at generation time.
23
+ (~36 LOC `class.js` + `button.yaml` + a2ui regen.)
24
+
25
+ ### Added — Loading state as first-class citizen (FB-12 P2)
26
+
27
+ - **`stat-ui loading` prop.** New `loading: Boolean` (reflect: true). When set,
28
+ the value + change slots render `<skeleton-ui>` shimmer placeholders and the
29
+ host gets `aria-busy="true"`. Label and icon are preserved as static
30
+ metadata. Toggle off when data arrives — slot textContent setters restore
31
+ cleanly. `composes:` now includes `skeleton-ui`. (~28 LOC `stat.js` + 9 LOC
32
+ `stat.yaml` + a2ui chunk regen.)
33
+ - **`table-ui` skeleton rows.** `loading=true` now renders N ghost
34
+ `[data-skeleton-row]` rows (N = `paginate` capped at 8, else 5) instead of
35
+ the legacy `<progress-ui>` overlay. Header + columns stay intact so layout
36
+ is preserved while data fetches. Cell widths cycle 60/80/70/50/90% so rows
37
+ read as natural data. Host gets `aria-busy="true"`. `composes:` now
38
+ includes `skeleton-ui`. Fixes yaml-vs-impl drift: the yaml description
39
+ always promised "skeleton rows" since the initial cut; the impl only
40
+ shipped the overlay. (~42 LOC `class.js` rewrite of `#renderOverlays` +
41
+ CSS rewrite of the `:scope[loading] [data-body]` rule.)
42
+
43
+ ### Added — `text-ui` overlay attributes (FB-10)
44
+
45
+ - **`size` / `color` / `weight` / `text-align` props on `text-ui`.** Pre-v0.6.18
46
+ the skill documented an overlay API (`<text-ui color="subtle" size="sm"
47
+ weight="semibold" text-align="center">`) but the substrate ignored those
48
+ attributes — they were valid HTML but no-op CSS. v0.6.18 implements them
49
+ as `:scope[attr="value"]` rules that override the `--text-*` CSS vars set
50
+ by `[variant]`. Enums:
51
+ - `size` — `sm | md | lg` → `--a-body-sm | --a-body-md | --a-body-lg`
52
+ - `color` — `default | subtle | strong | accent | danger | success | warning`
53
+ - `weight` — `regular | medium | semibold | bold`
54
+ - `text-align` — `start | center | end | justify` (sets `text-align` directly;
55
+ only takes effect when text-ui is block-like)
56
+ Permissive: unknown values are no-ops (variant default wins). Substrate now
57
+ matches the skill's already-documented intent.
58
+
59
+ ### Tests
60
+
61
+ - 9 new tests in `stat.test.js` (FB-12 P2 #1 coverage).
62
+ - 9 new tests in `table.test.js` (FB-12 P2 #3 coverage; uses RAF-based settle
63
+ helper because table-ui renders via `requestAnimationFrame`, not microtasks).
64
+ - 18 new tests in `text.test.js` for the overlay attributes (attribute
65
+ reflection × 18 values + CSS rule presence × 18 + 3 enum-vs-CSS
66
+ consistency checks + permissive-fallback smoke).
67
+ - **Total: 99/99 passing** (text 82 + stat 9 + table 9 — the existing text
68
+ variant tests still pass alongside the new overlay tests).
69
+
70
+ ### Deferred (see `outbox/FEEDBACK-12--...--followup.md`)
71
+
72
+ - **`chart-ui` `loading` prop (FB-12 P2 #2)** — design decision upstream.
73
+ `chart.examples.html` documents intentional "compose loading wrappers"
74
+ pattern; pivoting in a patch cut would silently lock in a direction.
75
+ - **`skeleton-ui` `variant` reconcile (FB-12 P3)** — two valid dispositions
76
+ (purge a2ui corpus refs / implement prop) need maintainer signoff.
77
+
78
+ ### No-op (FB-11 P3 substrate)
79
+
80
+ - Verified that `import { UIFeed } from '@adia-ai/web-components/components/feed/class'`
81
+ resolves correctly via the existing `./components/*/class` exports pattern.
82
+ No package.json change needed. The original ticket flagged
83
+ `/components/feed/feed.js` as failing — confirmed (Node exports wildcards
84
+ can't substitute `*` across multiple path segments) but the `/class`
85
+ shorthand is the canonical path and works today. Skill v2.11.0 documents
86
+ this.
87
+
88
+ ## [0.6.17] — 2026-05-21
89
+
90
+ ### Maintenance
91
+ - **Lockstep version bump only.** No source changes in this package; bumped to maintain the 9-package version coherence enforced by `scripts/release/check-lockstep.mjs`. Substantive v0.6.17 work shipped in `@adia-ai/web-modules` — collapsed-sidebar rules grew a forgiving fallback block for vanilla HTML consumers (`<button class="nav-item">`, `[slot="heading"]`) so text labels don't overflow the 48px rail. See `packages/web-modules/CHANGELOG.md#0617--2026-05-21` for details.
92
+
3
93
  ## [0.6.16] — 2026-05-21
4
94
 
5
95
  ### Maintenance
@@ -96,7 +96,14 @@
96
96
  ],
97
97
  "unevaluatedProperties": false,
98
98
  "x-adiaui": {
99
- "anti_patterns": [],
99
+ "anti_patterns": [
100
+ {
101
+ "description": "Beginning text= with a symbol that icon= already renders. icon=\"plus\" paints a Phosphor \"+\" glyph; text=\"+ New Item\" then renders the literal \"+\" too, so the symbol appears twice ([+ icon] [+ New Item]).",
102
+ "right": "<button-ui text=\"New Claim\" icon=\"plus\" variant=\"primary\"></button-ui>\n",
103
+ "rule": "Do not repeat the icon's glyph in text=. The icon provides the symbol; text= carries only the words. Applies to plus / minus / x / check / arrow icons.",
104
+ "wrong": "<button-ui text=\"+ New Claim\" icon=\"plus\" variant=\"primary\"></button-ui>\n"
105
+ }
106
+ ],
100
107
  "category": "action",
101
108
  "composes": [
102
109
  "icon-ui"
@@ -140,7 +140,19 @@ tokens:
140
140
  description: Inherited multiplier for padding
141
141
  a2ui:
142
142
  rules: []
143
- anti_patterns: []
143
+ anti_patterns:
144
+ - description: >-
145
+ Beginning text= with a symbol that icon= already renders. icon="plus"
146
+ paints a Phosphor "+" glyph; text="+ New Item" then renders the literal
147
+ "+" too, so the symbol appears twice ([+ icon] [+ New Item]).
148
+ wrong: |
149
+ <button-ui text="+ New Claim" icon="plus" variant="primary"></button-ui>
150
+ right: |
151
+ <button-ui text="New Claim" icon="plus" variant="primary"></button-ui>
152
+ rule: >-
153
+ Do not repeat the icon's glyph in text=. The icon provides the symbol;
154
+ text= carries only the words. Applies to plus / minus / x / check /
155
+ arrow icons.
144
156
  examples: []
145
157
  keywords: []
146
158
  synonyms: {}
@@ -16,6 +16,19 @@
16
16
  import { UIElement, signal, html } from '../../core/element.js';
17
17
  import { getIcon } from '../../core/icons.js';
18
18
 
19
+ // FEEDBACK-15: highest-frequency icon ⇄ text-prefix collisions. When an
20
+ // author writes e.g. text="+ New Item" icon="plus", the Phosphor glyph and
21
+ // the literal symbol both render — a doubled "+". Map is intentionally a
22
+ // curated high-frequency set, not exhaustive; the warning is non-breaking.
23
+ const ICON_TEXT_PREFIXES = {
24
+ plus: ['+', '+'],
25
+ minus: ['-', '−', '–'],
26
+ x: ['×', 'x ', 'X '],
27
+ check: ['✓', '✔'],
28
+ 'arrow-right': ['→', '>'],
29
+ 'arrow-left': ['←', '<'],
30
+ };
31
+
19
32
  export class UIButton extends UIElement {
20
33
  static properties = {
21
34
  text: { type: String, default: '', reflect: true },
@@ -57,6 +70,26 @@ export class UIButton extends UIElement {
57
70
  }
58
71
  }
59
72
 
73
+ // FEEDBACK-15: warn when text= begins with a symbol that icon= already
74
+ // renders (e.g. text="+ New Claim" icon="plus" → a doubled "+"). One-shot
75
+ // per element via WeakSet; warning-only — the button still renders. The
76
+ // text value is never mutated (text="+1" with no icon is a valid label).
77
+ if (this.icon && this.text) {
78
+ const prefixes = ICON_TEXT_PREFIXES[this.icon];
79
+ if (prefixes && prefixes.some((p) => this.text.startsWith(p))) {
80
+ if (!UIButton.#dupSymbolWarned.has(this)) {
81
+ UIButton.#dupSymbolWarned.add(this);
82
+ // eslint-disable-next-line no-console
83
+ console.warn(
84
+ `[button-ui] text="${this.text}" begins with a symbol that ` +
85
+ `icon="${this.icon}" already renders — the glyph appears twice. ` +
86
+ `Drop the leading symbol from text= (the icon provides it).`,
87
+ this,
88
+ );
89
+ }
90
+ }
91
+ }
92
+
60
93
  // §184 (v0.5.5, FEEDBACK-08 §8): icon-only a11y warning + title→aria-label
61
94
  // auto-derive. Two complementary mechanisms (consumer chooses):
62
95
  // (a) When [title="Undo"] is set on an icon-only button without an
@@ -97,6 +130,9 @@ export class UIButton extends UIElement {
97
130
  // is gone the entry is collected.
98
131
  static #a11yWarned = new WeakSet();
99
132
 
133
+ // FEEDBACK-15: one-shot set for the text/icon symbol-duplication warning.
134
+ static #dupSymbolWarned = new WeakSet();
135
+
100
136
  #onClick = (e) => {
101
137
  if (this.disabled) { e.stopPropagation(); return; }
102
138
  if (this.type === 'submit') {
@@ -31,6 +31,11 @@
31
31
  "type": "string",
32
32
  "default": ""
33
33
  },
34
+ "loading": {
35
+ "description": "Renders skeleton-ui shimmer placeholders in place of the value and change slots while data is fetching. Sets aria-busy=\"true\" on the host. Label and icon are preserved (they're static metadata, not fetched data). Toggle back to false when data arrives.",
36
+ "type": "boolean",
37
+ "default": false
38
+ },
34
39
  "trend": {
35
40
  "description": "Trend direction or narrative subtitle. Canonical values color the change badge (up=success, down=danger, neutral/flat=muted); any other string renders as caption-style text under the primary value.",
36
41
  "type": "string",
@@ -50,7 +55,8 @@
50
55
  "anti_patterns": [],
51
56
  "category": "display",
52
57
  "composes": [
53
- "icon-ui"
58
+ "icon-ui",
59
+ "skeleton-ui"
54
60
  ],
55
61
  "events": {},
56
62
  "examples": [
@@ -19,6 +19,8 @@ export class UIStat extends UIElement {
19
19
  icon: string;
20
20
  /** Eyebrow label describing the metric */
21
21
  label: string;
22
+ /** Renders skeleton-ui shimmer placeholders in place of the value and change slots while data is fetching. Sets aria-busy="true" on the host. Label and icon are preserved (they're static metadata, not fetched data). Toggle back to false when data arrives. */
23
+ loading: boolean;
22
24
  /** Trend direction or narrative subtitle. Canonical values color the change badge (up=success, down=danger, neutral/flat=muted); any other string renders as caption-style text under the primary value. */
23
25
  trend: string;
24
26
  /** The primary metric value to display */
@@ -12,11 +12,12 @@ import { UIElement } from '../../core/element.js';
12
12
 
13
13
  class UIStat extends UIElement {
14
14
  static properties = {
15
- value: { type: String, default: '', reflect: true },
16
- label: { type: String, default: '', reflect: true },
17
- change: { type: String, default: '', reflect: true },
18
- trend: { type: String, default: '', reflect: true },
19
- icon: { type: String, default: '', reflect: true },
15
+ value: { type: String, default: '', reflect: true },
16
+ label: { type: String, default: '', reflect: true },
17
+ change: { type: String, default: '', reflect: true },
18
+ trend: { type: String, default: '', reflect: true },
19
+ icon: { type: String, default: '', reflect: true },
20
+ loading: { type: Boolean, default: false, reflect: true },
20
21
  };
21
22
 
22
23
  static template = () => null;
@@ -59,6 +60,34 @@ class UIStat extends UIElement {
59
60
  render() {
60
61
  if (!this.#valueEl) return;
61
62
 
63
+ // ── Loading state ──
64
+ // When [loading], render skeleton-ui into the value + change slots and set
65
+ // aria-busy on the host. Label is preserved (it's static metadata, not
66
+ // fetched data). Icon is preserved too. When loading flips to false on
67
+ // first non-empty value write, slots restore to text content automatically.
68
+ if (this.loading) {
69
+ this.setAttribute('aria-busy', 'true');
70
+ // Use innerHTML so skeleton-ui auto-registers via the barrel; consumers
71
+ // who tree-shake skeleton-ui out will see plain shimmer-less placeholders.
72
+ // Width 60% / 40% / 2em / 1em chosen to roughly mirror the rendered
73
+ // value+change visual mass without being so wide as to look like text.
74
+ this.#valueEl.textContent = '';
75
+ this.#valueEl.innerHTML = '<skeleton-ui width="60%" height="2em" radius="sm"></skeleton-ui>';
76
+ this.#changeEl.textContent = '';
77
+ this.#changeEl.innerHTML = '<skeleton-ui width="40%" height="1em" radius="sm"></skeleton-ui>';
78
+ this.#changeEl.hidden = false;
79
+ // Icon stays as-is (metadata, not data).
80
+ if (this.icon) {
81
+ this.#iconEl.setAttribute('name', this.icon);
82
+ this.#iconEl.hidden = false;
83
+ } else {
84
+ this.#iconEl.hidden = true;
85
+ }
86
+ this.#labelEl.textContent = this.label;
87
+ return;
88
+ }
89
+
90
+ this.removeAttribute('aria-busy');
62
91
  this.#valueEl.textContent = this.value;
63
92
  this.#labelEl.textContent = this.label;
64
93
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * stat-ui — focused unit tests for the v0.6.18 `loading` boolean prop
3
+ * (FB-12 P2 resolution).
4
+ *
5
+ * Pre-v0.6.18: stat-ui rendered empty/zero values during data fetch with no
6
+ * visual indication. Consumers were forced to hand-roll skeleton-card
7
+ * workarounds with their own `@keyframes` CSS — duplicating skeleton-ui.
8
+ *
9
+ * v0.6.18 adds `loading: Boolean`. When set:
10
+ * - value + change slots render <skeleton-ui> shimmer placeholders
11
+ * - aria-busy="true" on the host
12
+ * - label + icon (static metadata) are preserved
13
+ * - toggling off restores text content + clears aria-busy
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach } from 'vitest';
17
+ import '../../core/element.js';
18
+ import './stat.js';
19
+ // skeleton-ui is referenced by stat-ui at runtime when [loading]; load it so
20
+ // the element gets defined (otherwise the inner <skeleton-ui> stays an
21
+ // HTMLUnknownElement, which is still observable in the DOM but the assertions
22
+ // below want to confirm registration via tagName.)
23
+ import '../skeleton/skeleton.js';
24
+
25
+ const tick = () => new Promise((r) => queueMicrotask(r));
26
+
27
+ function mount(html) {
28
+ const wrap = document.createElement('div');
29
+ wrap.innerHTML = html;
30
+ document.body.appendChild(wrap);
31
+ return wrap.firstElementChild;
32
+ }
33
+
34
+ describe('stat-ui — v0.6.18 loading prop (FB-12 P2)', () => {
35
+ beforeEach(() => { document.body.innerHTML = ''; });
36
+
37
+ it('defaults loading to false; no aria-busy on host', async () => {
38
+ const el = mount('<stat-ui label="Total" value="1,234"></stat-ui>');
39
+ await tick();
40
+ expect(el.loading).toBe(false);
41
+ expect(el.hasAttribute('loading')).toBe(false);
42
+ expect(el.getAttribute('aria-busy')).toBeNull();
43
+ });
44
+
45
+ it('reflects [loading] attribute to the property', () => {
46
+ const el = mount('<stat-ui label="Total" loading></stat-ui>');
47
+ expect(el.loading).toBe(true);
48
+ expect(el.hasAttribute('loading')).toBe(true);
49
+ });
50
+
51
+ it('sets aria-busy="true" on host when [loading]', async () => {
52
+ const el = mount('<stat-ui label="Total" loading></stat-ui>');
53
+ await tick();
54
+ expect(el.getAttribute('aria-busy')).toBe('true');
55
+ });
56
+
57
+ it('renders a <skeleton-ui> inside the value slot when [loading]', async () => {
58
+ const el = mount('<stat-ui label="Total" loading></stat-ui>');
59
+ await tick();
60
+ const valueSlot = el.querySelector(':scope > [slot="value"]');
61
+ expect(valueSlot).not.toBeNull();
62
+ const sk = valueSlot.querySelector('skeleton-ui');
63
+ expect(sk).not.toBeNull();
64
+ expect(sk.tagName.toLowerCase()).toBe('skeleton-ui');
65
+ });
66
+
67
+ it('renders a <skeleton-ui> inside the change slot when [loading]', async () => {
68
+ const el = mount('<stat-ui label="Total" loading></stat-ui>');
69
+ await tick();
70
+ const changeSlot = el.querySelector(':scope > [slot="change"]');
71
+ expect(changeSlot).not.toBeNull();
72
+ expect(changeSlot.hidden).toBe(false);
73
+ const sk = changeSlot.querySelector('skeleton-ui');
74
+ expect(sk).not.toBeNull();
75
+ });
76
+
77
+ it('preserves label text content when [loading] (label is static metadata)', async () => {
78
+ const el = mount('<stat-ui label="Total Users" loading></stat-ui>');
79
+ await tick();
80
+ const labelSlot = el.querySelector(':scope > [slot="label"]');
81
+ expect(labelSlot.textContent).toBe('Total Users');
82
+ });
83
+
84
+ it('restores value + change text + clears aria-busy when loading toggles off', async () => {
85
+ const el = mount('<stat-ui label="Total" loading></stat-ui>');
86
+ await tick();
87
+ // Now flip off + add real values
88
+ el.removeAttribute('loading');
89
+ el.setAttribute('value', '1,234');
90
+ el.setAttribute('change', '+12%');
91
+ await tick();
92
+ expect(el.getAttribute('aria-busy')).toBeNull();
93
+ expect(el.querySelector(':scope > [slot="value"]').textContent).toBe('1,234');
94
+ expect(el.querySelector(':scope > [slot="change"]').textContent).toBe('+12%');
95
+ // skeleton-ui children should be gone (textContent setter clobbers innerHTML)
96
+ expect(el.querySelector(':scope > [slot="value"] skeleton-ui')).toBeNull();
97
+ expect(el.querySelector(':scope > [slot="change"] skeleton-ui')).toBeNull();
98
+ });
99
+
100
+ it('keeps icon visible when [loading] if icon prop is set', async () => {
101
+ const el = mount('<stat-ui label="Total" icon="users" loading></stat-ui>');
102
+ await tick();
103
+ const iconSlot = el.querySelector(':scope > [slot="icon"]');
104
+ expect(iconSlot).not.toBeNull();
105
+ expect(iconSlot.hidden).toBe(false);
106
+ expect(iconSlot.getAttribute('name')).toBe('users');
107
+ });
108
+ });
@@ -5,6 +5,7 @@ name: UIStat
5
5
  tag: stat-ui
6
6
  composes:
7
7
  - icon-ui
8
+ - skeleton-ui
8
9
  component: Stat
9
10
  category: display
10
11
  version: 1
@@ -22,6 +23,14 @@ props:
22
23
  description: Eyebrow label describing the metric
23
24
  type: string
24
25
  default: ""
26
+ loading:
27
+ description: >-
28
+ Renders skeleton-ui shimmer placeholders in place of the value and change
29
+ slots while data is fetching. Sets aria-busy="true" on the host. Label
30
+ and icon are preserved (they're static metadata, not fetched data).
31
+ Toggle back to false when data arrives.
32
+ type: boolean
33
+ default: false
25
34
  trend:
26
35
  description: >-
27
36
  Trend direction or narrative subtitle. Canonical values color the change
@@ -844,18 +844,52 @@ export class UITable extends UIElement {
844
844
  let loadingEl = this.querySelector(':scope > [data-loading]');
845
845
 
846
846
  if (this.loading) {
847
- // Show loading overlay
848
- if (!loadingEl) {
849
- loadingEl = document.createElement('div');
850
- loadingEl.setAttribute('data-loading', '');
851
- const prog = document.createElement('progress-ui');
852
- prog.setAttribute('indeterminate', '');
853
- loadingEl.appendChild(prog);
854
- body.after(loadingEl);
847
+ // Skeleton rows: render N ghost rows of <skeleton-ui> cells inside the
848
+ // body rowgroup. Preserves table layout (header + columns intact) while
849
+ // signalling pending data. aria-busy on host announces the busy state.
850
+ // Old behavior (progress-ui spinner overlay) hid the table layout — the
851
+ // yaml description always promised "skeleton rows", impl shipped overlay
852
+ // (yaml-vs-impl drift fixed in v0.6.18 per FB-12 P2).
853
+ this.setAttribute('aria-busy', 'true');
854
+ // Real rows reconciled above already cleared if data is empty; if data
855
+ // is present, leave it but layer skeletons on top of the body. Simpler:
856
+ // always replace body children with skeleton rows when loading.
857
+ const visCols = this.#visibleColumns;
858
+ const skeletonRowCount = this.paginate > 0 ? Math.min(this.paginate, 8) : 5;
859
+ const totalCellCount =
860
+ (this.expandable ? 1 : 0) +
861
+ (this.selectable ? 1 : 0) +
862
+ visCols.length;
863
+
864
+ // Remove all existing body children (real rows + detail rows) — they're
865
+ // replaced by skeleton rows while loading.
866
+ while (body.firstChild) body.firstChild.remove();
867
+
868
+ for (let r = 0; r < skeletonRowCount; r++) {
869
+ const row = document.createElement('div');
870
+ row.setAttribute('role', 'row');
871
+ row.setAttribute('data-skeleton-row', '');
872
+ for (let c = 0; c < totalCellCount; c++) {
873
+ const cell = document.createElement('div');
874
+ cell.setAttribute('role', 'gridcell');
875
+ const sk = document.createElement('skeleton-ui');
876
+ // Vary width across cells so the row reads as natural data rows,
877
+ // not a uniform bar. Pattern: 60% / 80% / 70% / 50% / 90%, cycling.
878
+ const widths = ['60%', '80%', '70%', '50%', '90%'];
879
+ sk.setAttribute('width', widths[c % widths.length]);
880
+ sk.setAttribute('height', '1em');
881
+ sk.setAttribute('radius', 'sm');
882
+ cell.appendChild(sk);
883
+ row.appendChild(cell);
884
+ }
885
+ body.appendChild(row);
855
886
  }
887
+ // Remove legacy overlay if it lingers from a prior render.
888
+ if (loadingEl) loadingEl.remove();
856
889
  if (emptyEl) emptyEl.remove();
857
890
  } else if (this.#data.length === 0) {
858
891
  // Show empty state
892
+ this.removeAttribute('aria-busy');
859
893
  if (!emptyEl) {
860
894
  emptyEl = document.createElement('div');
861
895
  emptyEl.setAttribute('data-empty', '');
@@ -870,6 +904,7 @@ export class UITable extends UIElement {
870
904
  if (loadingEl) loadingEl.remove();
871
905
  } else {
872
906
  // Remove both overlays
907
+ this.removeAttribute('aria-busy');
873
908
  if (emptyEl) emptyEl.remove();
874
909
  if (loadingEl) loadingEl.remove();
875
910
  }
@@ -42,7 +42,7 @@
42
42
  "default": false
43
43
  },
44
44
  "loading": {
45
- "description": "Shows a loading overlay and skeleton rows. Sets aria-busy=\"true\". Data updates are deferred until loading is set back to false.",
45
+ "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.",
46
46
  "type": "boolean",
47
47
  "default": false
48
48
  },
@@ -89,6 +89,7 @@
89
89
  "icon-ui",
90
90
  "progress-ui",
91
91
  "pagination-ui",
92
+ "skeleton-ui",
92
93
  "badge-ui"
93
94
  ],
94
95
  "events": {
@@ -364,13 +364,29 @@
364
364
  color: var(--table-fg-disabled);
365
365
  }
366
366
 
367
- /* ═══════ Loading ═══════ */
368
-
369
- :scope[loading] [data-body] {
370
- opacity: 0.5;
367
+ /* ═══════ Loading (skeleton rows) ═══════
368
+ Skeleton rows replace real rows while [loading] is set on the host
369
+ (see class.js #renderOverlays). Each row is a [data-skeleton-row]
370
+ containing <skeleton-ui> cells. Inherit body-row layout so column
371
+ widths track the header, then suppress hover/striping/click states
372
+ (no real data to interact with). */
373
+
374
+ [data-body] > [data-skeleton-row] {
371
375
  pointer-events: none;
372
376
  }
373
377
 
378
+ [data-body] > [data-skeleton-row]:hover {
379
+ background: transparent;
380
+ }
381
+
382
+ :scope[striped] [data-body] > [data-skeleton-row]:nth-child(even) {
383
+ background: transparent;
384
+ }
385
+
386
+ /* No-op the dim-when-loading rule the old overlay relied on: with skeleton
387
+ rows, the body IS the loading affordance — dimming it would just blur
388
+ the shimmer. */
389
+
374
390
  /* ═══════ Filter UI ═══════ */
375
391
 
376
392
  [data-filter-btn] {
@@ -86,7 +86,7 @@ export class UITable extends UIElement {
86
86
  density: 'compact' | 'standard' | 'comfortable';
87
87
  /** Enable row expansion */
88
88
  expandable: boolean;
89
- /** Shows a loading overlay and skeleton rows. Sets aria-busy="true". Data updates are deferred until loading is set back to false. */
89
+ /** 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. */
90
90
  loading: boolean;
91
91
  /** Rows per page. 0 = show all rows without pagination. When > 0, renders a pagination bar below the table. */
92
92
  paginate: number;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * table-ui — focused unit tests for the v0.6.18 loading=skeleton-rows
3
+ * behavior change (FB-12 P2 resolution).
4
+ *
5
+ * Pre-v0.6.18: `loading=true` rendered a `<progress-ui>` spinner overlay
6
+ * inside the body via `[data-loading]`. The yaml description always said
7
+ * "Shows a loading overlay AND skeleton rows" but the impl only did the
8
+ * overlay — yaml-vs-impl drift since the initial table cut.
9
+ *
10
+ * v0.6.18 changes the loading branch to render N ghost skeleton rows
11
+ * (N = paginate if set, else 5) inside the [data-body] rowgroup. Header +
12
+ * columns stay intact so layout is preserved. Sets aria-busy="true" on the
13
+ * host. Old `[data-loading]` overlay element is removed if it lingers.
14
+ *
15
+ * Note: table-ui needs columns + data set imperatively (via the .columns /
16
+ * .data properties on the element) — declarative <col-def> children also
17
+ * work but require the col-def element to be registered first. Tests use
18
+ * the imperative path.
19
+ */
20
+
21
+ import { describe, it, expect, beforeEach } from 'vitest';
22
+ import '../../core/element.js';
23
+ import './table.js';
24
+ import '../skeleton/skeleton.js';
25
+
26
+ const tick = () => new Promise((r) => queueMicrotask(r));
27
+ const raf = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
28
+
29
+ function mount(html) {
30
+ const wrap = document.createElement('div');
31
+ wrap.innerHTML = html;
32
+ document.body.appendChild(wrap);
33
+ return wrap.firstElementChild;
34
+ }
35
+
36
+ const COLS = [
37
+ { key: 'id', label: 'ID' },
38
+ { key: 'name', label: 'Name' },
39
+ { key: 'email', label: 'Email' },
40
+ ];
41
+ const ROWS = [
42
+ { id: 1, name: 'Alice', email: 'alice@acme.com' },
43
+ { id: 2, name: 'Bob', email: 'bob@acme.com' },
44
+ ];
45
+
46
+ describe('table-ui — v0.6.18 loading=skeleton-rows (FB-12 P2)', () => {
47
+ beforeEach(() => { document.body.innerHTML = ''; });
48
+
49
+ it('renders skeleton rows in [data-body] when [loading] is set', async () => {
50
+ const el = mount('<table-ui></table-ui>');
51
+ el.columns = COLS;
52
+ el.data = ROWS;
53
+ await tick();
54
+ el.setAttribute('loading', '');
55
+ await tick();
56
+ const body = el.querySelector(':scope > [data-body]');
57
+ expect(body).not.toBeNull();
58
+ const skRows = body.querySelectorAll('[data-skeleton-row]');
59
+ expect(skRows.length).toBeGreaterThan(0);
60
+ });
61
+
62
+ it('sets aria-busy="true" on the host when [loading]', async () => {
63
+ const el = mount('<table-ui></table-ui>');
64
+ el.columns = COLS;
65
+ el.data = ROWS;
66
+ await tick();
67
+ el.setAttribute('loading', '');
68
+ await tick();
69
+ expect(el.getAttribute('aria-busy')).toBe('true');
70
+ });
71
+
72
+ it('replaces real body rows with skeleton rows when [loading]', async () => {
73
+ const el = mount('<table-ui></table-ui>');
74
+ el.columns = COLS;
75
+ el.data = ROWS;
76
+ // table-ui uses requestAnimationFrame in #requestRender(), not microtasks.
77
+ // queueMicrotask-based tick() won't drain RAF callbacks — must await raf().
78
+ // Loop up to 5 RAF cycles in case the initial mount needs multiple renders
79
+ // to settle (columns set → render → data set → render).
80
+ for (let i = 0; i < 5; i++) {
81
+ await raf();
82
+ const body = el.querySelector(':scope > [data-body]');
83
+ if (body && body.children.length >= 2) break;
84
+ }
85
+ const body = el.querySelector(':scope > [data-body]');
86
+ expect(body).not.toBeNull();
87
+ // Real rows present (2)
88
+ const realRows = body.querySelectorAll(':scope > [role="row"]:not([data-skeleton-row])');
89
+ expect(realRows.length).toBe(2);
90
+ el.setAttribute('loading', '');
91
+ await raf();
92
+ // Real rows gone, skeleton rows present
93
+ expect(body.querySelectorAll(':scope > [role="row"]:not([data-skeleton-row])').length).toBe(0);
94
+ expect(body.querySelectorAll(':scope > [data-skeleton-row]').length).toBeGreaterThan(0);
95
+ });
96
+
97
+ it('each skeleton row has cell count matching column count', async () => {
98
+ const el = mount('<table-ui></table-ui>');
99
+ el.columns = COLS; // 3 columns
100
+ el.data = ROWS;
101
+ await tick();
102
+ el.setAttribute('loading', '');
103
+ await tick();
104
+ const skRow = el.querySelector('[data-skeleton-row]');
105
+ expect(skRow.children.length).toBe(3);
106
+ });
107
+
108
+ it('each skeleton cell contains a <skeleton-ui> shimmer element', async () => {
109
+ const el = mount('<table-ui></table-ui>');
110
+ el.columns = COLS;
111
+ el.data = ROWS;
112
+ await tick();
113
+ el.setAttribute('loading', '');
114
+ await tick();
115
+ const skCells = el.querySelectorAll('[data-skeleton-row] > [role="gridcell"]');
116
+ expect(skCells.length).toBeGreaterThan(0);
117
+ for (const cell of skCells) {
118
+ const sk = cell.querySelector('skeleton-ui');
119
+ expect(sk).not.toBeNull();
120
+ expect(sk.tagName.toLowerCase()).toBe('skeleton-ui');
121
+ }
122
+ });
123
+
124
+ it('does NOT render a [data-loading] overlay element (old behavior removed)', async () => {
125
+ const el = mount('<table-ui></table-ui>');
126
+ el.columns = COLS;
127
+ el.data = ROWS;
128
+ await tick();
129
+ el.setAttribute('loading', '');
130
+ await tick();
131
+ // Old impl created <div data-loading> with <progress-ui> child.
132
+ // v0.6.18 does NOT — skeleton rows ARE the loading affordance.
133
+ expect(el.querySelector(':scope > [data-loading]')).toBeNull();
134
+ });
135
+
136
+ it('restores real rows + clears aria-busy when loading toggles off', async () => {
137
+ const el = mount('<table-ui></table-ui>');
138
+ el.columns = COLS;
139
+ el.data = ROWS;
140
+ await tick();
141
+ el.setAttribute('loading', '');
142
+ await tick();
143
+ el.removeAttribute('loading');
144
+ await tick();
145
+ expect(el.getAttribute('aria-busy')).toBeNull();
146
+ expect(el.querySelectorAll('[data-body] > [data-skeleton-row]').length).toBe(0);
147
+ expect(el.querySelectorAll('[data-body] > [role="row"]').length).toBe(2);
148
+ });
149
+
150
+ it('skeleton row count tracks paginate when set, capped at 8', async () => {
151
+ const el = mount('<table-ui paginate="3"></table-ui>');
152
+ el.columns = COLS;
153
+ el.data = ROWS;
154
+ await tick();
155
+ el.setAttribute('loading', '');
156
+ await tick();
157
+ expect(el.querySelectorAll('[data-body] > [data-skeleton-row]').length).toBe(3);
158
+ });
159
+
160
+ it('preserves header row when [loading]', async () => {
161
+ const el = mount('<table-ui></table-ui>');
162
+ el.columns = COLS;
163
+ el.data = ROWS;
164
+ await tick();
165
+ const beforeHeader = el.querySelector(':scope > [data-header]');
166
+ expect(beforeHeader).not.toBeNull();
167
+ el.setAttribute('loading', '');
168
+ await tick();
169
+ const afterHeader = el.querySelector(':scope > [data-header]');
170
+ expect(afterHeader).not.toBeNull();
171
+ // Header cells unchanged in count
172
+ expect(afterHeader.children.length).toBe(3);
173
+ });
174
+ });
@@ -15,6 +15,7 @@ composes:
15
15
  - icon-ui
16
16
  - progress-ui
17
17
  - pagination-ui
18
+ - skeleton-ui
18
19
  - badge-ui
19
20
  props:
20
21
  columns:
@@ -39,8 +40,11 @@ props:
39
40
  type: boolean
40
41
  default: false
41
42
  loading:
42
- description: Shows a loading overlay and skeleton rows. Sets aria-busy="true". Data updates are
43
- deferred until loading is set back to false.
43
+ description: >-
44
+ Renders N ghost skeleton rows in place of the body data (count derived
45
+ from `paginate` if set, else 5). Header + columns stay intact so the
46
+ table layout is preserved while data fetches. Sets aria-busy="true" on
47
+ the host. Data updates are deferred until loading is set back to false.
44
48
  type: boolean
45
49
  default: false
46
50
  reflect: true
@@ -28,10 +28,20 @@ import { UIElement } from '../../core/element.js';
28
28
 
29
29
  export class UIText extends UIElement {
30
30
  static properties = {
31
- variant: { type: String, default: 'body', reflect: true },
32
- strong: { type: Boolean, default: false, reflect: true },
33
- truncate: { type: Boolean, default: false, reflect: true },
34
- lines: { type: Number, default: 0, reflect: true },
31
+ variant: { type: String, default: 'body', reflect: true },
32
+ strong: { type: Boolean, default: false, reflect: true },
33
+ truncate: { type: Boolean, default: false, reflect: true },
34
+ lines: { type: Number, default: 0, reflect: true },
35
+ // ── v0.6.18 (FB-10) — finer-control overrides on top of `variant` ──
36
+ // Pre-v0.6.18, sizing/coloring/weighting required choosing a different
37
+ // `variant` (e.g. `label-sm` → `caption`). The skill already documents
38
+ // an intuitive overlay API (color="subtle", size="sm", weight="semibold",
39
+ // text-align="center"); v0.6.18 implements it. Each prop is an
40
+ // attribute selector in text.css that overrides the variant default.
41
+ size: { type: String, default: '', reflect: true },
42
+ color: { type: String, default: '', reflect: true },
43
+ weight: { type: String, default: '', reflect: true },
44
+ 'text-align': { type: String, default: '', reflect: true },
35
45
  };
36
46
 
37
47
  static template = () => null;
@@ -13,6 +13,20 @@
13
13
  }
14
14
  ],
15
15
  "properties": {
16
+ "color": {
17
+ "description": "Override the variant's color token. Permissive: unknown values are no-ops (variant color wins). Added v0.6.18 (FB-10).",
18
+ "type": "string",
19
+ "enum": [
20
+ "default",
21
+ "subtle",
22
+ "strong",
23
+ "accent",
24
+ "danger",
25
+ "success",
26
+ "warning"
27
+ ],
28
+ "default": ""
29
+ },
16
30
  "component": {
17
31
  "const": "Text"
18
32
  },
@@ -21,11 +35,32 @@
21
35
  "type": "number",
22
36
  "default": 0
23
37
  },
38
+ "size": {
39
+ "description": "Override the variant's font-size on the body ladder. Maps to --a-body-sm / --a-body-md / --a-body-lg. Permissive: unknown values are no-ops (variant size wins). Added v0.6.18 (FB-10).",
40
+ "type": "string",
41
+ "enum": [
42
+ "sm",
43
+ "md",
44
+ "lg"
45
+ ],
46
+ "default": ""
47
+ },
24
48
  "strong": {
25
49
  "description": "When true, applies stronger emphasis (heavier weight + accent color). Styled via :scope[strong] in text.css. Use instead of variant=heading when you want a single emphasized word inline in body copy.",
26
50
  "type": "boolean",
27
51
  "default": false
28
52
  },
53
+ "text-align": {
54
+ "description": "Override text alignment. Note: text-ui defaults to display:inline, so this only takes effect when text-ui is block-like (wrapping or parent display:block/grid). Added v0.6.18 (FB-10).",
55
+ "type": "string",
56
+ "enum": [
57
+ "start",
58
+ "center",
59
+ "end",
60
+ "justify"
61
+ ],
62
+ "default": ""
63
+ },
29
64
  "textContent": {
30
65
  "description": "Display text content. The main payload field for Text components extracted from HTML.",
31
66
  "$ref": "common_types.json#/$defs/DynamicString"
@@ -67,6 +102,17 @@
67
102
  "section": "Inline form-group / navlist heading (visual rank H4). Small-cap. Use for form group labels, nav list headings.",
68
103
  "subsection": "Sub-landmark within a section (visual rank H3). 14px / semibold. Use for card titles within a section."
69
104
  }
105
+ },
106
+ "weight": {
107
+ "description": "Override the variant's font-weight. Maps to --a-weight / --a-weight-medium / --a-weight-semibold / --a-weight-bold. Permissive: unknown values are no-ops (variant weight wins). Added v0.6.18 (FB-10).",
108
+ "type": "string",
109
+ "enum": [
110
+ "regular",
111
+ "medium",
112
+ "semibold",
113
+ "bold"
114
+ ],
115
+ "default": ""
70
116
  }
71
117
  },
72
118
  "required": [
@@ -61,6 +61,47 @@
61
61
  :scope[variant="deck"] { --text-family: var(--a-deck-family); --text-weight: var(--a-deck-weight); --text-size: var(--a-deck-size); --text-leading: var(--a-deck-leading); --text-tracking: var(--a-deck-tracking); --text-case: var(--a-deck-case); --text-color: var(--a-deck-color); }
62
62
  :scope[variant="metric"] { --text-family: var(--a-metric-family); --text-weight: var(--a-metric-weight); --text-size: var(--a-metric-size); --text-leading: var(--a-metric-leading); --text-tracking: var(--a-metric-tracking); --text-case: var(--a-metric-case); --text-color: var(--a-metric-color); }
63
63
 
64
+ /* ── v0.6.18 (FB-10) — finer-control overrides on top of `variant` ──
65
+ The skill always documented an overlay API (color="subtle",
66
+ size="sm", weight="semibold", text-align="center"). Pre-v0.6.18 these
67
+ attributes existed only as documentation — text-ui ignored them and
68
+ rendered the variant default. v0.6.18 implements them as attribute
69
+ selectors that override the `--text-*` CSS variables set by [variant].
70
+ Each is intentionally permissive: unknown values fall back to the
71
+ variant's value rather than throwing. Authoring `<text-ui color="...">`
72
+ with an unknown color = no-op (variant color wins). */
73
+
74
+ /* size — sm | md | lg (md is the body default ~15-16px) */
75
+ :scope[size="sm"] { --text-size: var(--a-body-sm); }
76
+ :scope[size="md"] { --text-size: var(--a-body-md); }
77
+ :scope[size="lg"] { --text-size: var(--a-body-lg); }
78
+
79
+ /* color — default | subtle | strong | accent | danger | success | warning
80
+ (no value = variant default; "default" is an explicit reset). */
81
+ :scope[color="default"] { --text-color: var(--a-fg); }
82
+ :scope[color="subtle"] { --text-color: var(--a-fg-muted); }
83
+ :scope[color="strong"] { --text-color: var(--a-fg-strong); }
84
+ :scope[color="accent"] { --text-color: var(--a-accent); }
85
+ :scope[color="danger"] { --text-color: var(--a-danger-bg); }
86
+ :scope[color="success"] { --text-color: var(--a-success-bg); }
87
+ :scope[color="warning"] { --text-color: var(--a-warning-bg); }
88
+
89
+ /* weight — regular | medium | semibold | bold */
90
+ :scope[weight="regular"] { --text-weight: var(--a-weight); }
91
+ :scope[weight="medium"] { --text-weight: var(--a-weight-medium); }
92
+ :scope[weight="semibold"] { --text-weight: var(--a-weight-semibold); }
93
+ :scope[weight="bold"] { --text-weight: var(--a-weight-bold); }
94
+
95
+ /* text-align — start | center | end | justify */
96
+ :scope[text-align="start"] { text-align: start; }
97
+ :scope[text-align="center"] { text-align: center; }
98
+ :scope[text-align="end"] { text-align: end; }
99
+ :scope[text-align="justify"] { text-align: justify; }
100
+ /* Note: `<text-ui>` defaults to display:inline. text-align only takes
101
+ effect when text-ui itself is block-like (e.g. via wrapping or a
102
+ `display: block`/`grid` parent). The override-attribute pattern keeps
103
+ the inline default but lets consumers flip alignment with one attr. */
104
+
64
105
  /* ── Truncation (single-line) ── */
65
106
  :scope[truncate] {
66
107
  overflow: hidden;
@@ -39,8 +39,12 @@ export type UITextVariant =
39
39
  | 'code';
40
40
 
41
41
  export class UIText extends UIElement {
42
+ /** Override the variant's color token. Permissive: unknown values are no-ops (variant color wins). Added v0.6.18 (FB-10). */
43
+ color: 'default' | 'subtle' | 'strong' | 'accent' | 'danger' | 'success' | 'warning';
42
44
  /** Multi-line clamp count (0 = no clamp) */
43
45
  lines: number;
46
+ /** Override the variant's font-size on the body ladder. Maps to --a-body-sm / --a-body-md / --a-body-lg. Permissive: unknown values are no-ops (variant size wins). Added v0.6.18 (FB-10). */
47
+ size: 'sm' | 'md' | 'lg';
44
48
  /** When true, applies stronger emphasis (heavier weight + accent color). Styled via :scope[strong] in text.css. Use instead of variant=heading when you want a single emphasized word inline in body copy. */
45
49
  strong: boolean;
46
50
  /** Display text content. The main payload field for Text components extracted from HTML. */
@@ -57,4 +61,6 @@ For semantic headings, wrap with native `<h1>`-`<h6>` OR add
57
61
  labels (eyebrows, kickers, captions, deck), the presentational default
58
62
  is correct. The §221k chooser guide in USAGE.md documents picker heuristics. */
59
63
  variant: UITextVariant;
64
+ /** Override the variant's font-weight. Maps to --a-weight / --a-weight-medium / --a-weight-semibold / --a-weight-bold. Permissive: unknown values are no-ops (variant weight wins). Added v0.6.18 (FB-10). */
65
+ weight: 'regular' | 'medium' | 'semibold' | 'bold';
60
66
  }
@@ -110,3 +110,93 @@ describe('text-ui §210 — variant enum vs CSS rule completeness', () => {
110
110
  }
111
111
  });
112
112
  });
113
+
114
+ // ─────────────────────────────────────────────────────────────────────
115
+ // v0.6.18 (FB-10) — overlay attributes on top of `variant`
116
+ // ─────────────────────────────────────────────────────────────────────
117
+
118
+ describe('text-ui v0.6.18 — overlay props (FB-10)', () => {
119
+ beforeEach(() => { document.body.innerHTML = ''; });
120
+
121
+ // ── Attribute reflection: each new prop reflects to the host ──
122
+ it.each([
123
+ ['size', 'sm'],
124
+ ['size', 'md'],
125
+ ['size', 'lg'],
126
+ ['color', 'subtle'],
127
+ ['color', 'strong'],
128
+ ['color', 'accent'],
129
+ ['color', 'danger'],
130
+ ['color', 'success'],
131
+ ['color', 'warning'],
132
+ ['color', 'default'],
133
+ ['weight', 'regular'],
134
+ ['weight', 'medium'],
135
+ ['weight', 'semibold'],
136
+ ['weight', 'bold'],
137
+ ['text-align', 'start'],
138
+ ['text-align', 'center'],
139
+ ['text-align', 'end'],
140
+ ['text-align', 'justify'],
141
+ ])('<text-ui %s="%s"> reflects the attribute', async (prop, value) => {
142
+ const el = mount(`<text-ui ${prop}="${value}">x</text-ui>`);
143
+ await tick();
144
+ expect(el.getAttribute(prop)).toBe(value);
145
+ });
146
+
147
+ // ── CSS-side: every documented value has a :scope[attr="value"] rule ──
148
+ const sizes = ['sm', 'md', 'lg'];
149
+ const colors = ['default', 'subtle', 'strong', 'accent', 'danger', 'success', 'warning'];
150
+ const weights = ['regular', 'medium', 'semibold', 'bold'];
151
+ const aligns = ['start', 'center', 'end', 'justify'];
152
+
153
+ it.each(sizes)('text.css ships :scope[size="%s"] rule', (s) => {
154
+ expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[size="${s}"\\]`));
155
+ });
156
+
157
+ it.each(colors)('text.css ships :scope[color="%s"] rule', (c) => {
158
+ expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[color="${c}"\\]`));
159
+ });
160
+
161
+ it.each(weights)('text.css ships :scope[weight="%s"] rule', (w) => {
162
+ expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[weight="${w}"\\]`));
163
+ });
164
+
165
+ it.each(aligns)('text.css ships :scope[text-align="%s"] rule', (a) => {
166
+ expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[text-align="${a}"\\]`));
167
+ });
168
+
169
+ // ── yaml-vs-impl consistency: every prop in the a2ui enum has a CSS rule ──
170
+ it('a2ui.json color enum matches CSS rules 1:1', () => {
171
+ const colorEnum = TEXT_A2UI.properties.color?.enum ?? [];
172
+ expect(colorEnum.sort()).toEqual([...colors].sort());
173
+ for (const c of colorEnum) {
174
+ expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[color="${c}"\\]`));
175
+ }
176
+ });
177
+
178
+ it('a2ui.json size enum matches CSS rules 1:1', () => {
179
+ const sizeEnum = TEXT_A2UI.properties.size?.enum ?? [];
180
+ expect(sizeEnum.sort()).toEqual([...sizes].sort());
181
+ for (const s of sizeEnum) {
182
+ expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[size="${s}"\\]`));
183
+ }
184
+ });
185
+
186
+ it('a2ui.json weight enum matches CSS rules 1:1', () => {
187
+ const weightEnum = TEXT_A2UI.properties.weight?.enum ?? [];
188
+ expect(weightEnum.sort()).toEqual([...weights].sort());
189
+ for (const w of weightEnum) {
190
+ expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[weight="${w}"\\]`));
191
+ }
192
+ });
193
+
194
+ // ── Permissive fallback: unknown values are no-ops (no JS error) ──
195
+ it('unknown color value renders without throwing', async () => {
196
+ const el = mount('<text-ui color="banana">x</text-ui>');
197
+ await tick();
198
+ expect(el.getAttribute('color')).toBe('banana');
199
+ // The variant default wins; no CSS rule matches "banana". Test the
200
+ // shape: no thrown error during mount, no console.error.
201
+ });
202
+ });
@@ -8,20 +8,56 @@ category: display
8
8
  version: 1
9
9
  description: Typography wrapper that applies role tokens. Supports truncation and line clamping.
10
10
  props:
11
+ color:
12
+ description: >-
13
+ Override the variant's color token. Permissive: unknown values are
14
+ no-ops (variant color wins). Added v0.6.18 (FB-10).
15
+ type: string
16
+ enum: ["default", "subtle", "strong", "accent", "danger", "success", "warning"]
17
+ default: ""
18
+ reflect: true
11
19
  lines:
12
20
  description: Multi-line clamp count (0 = no clamp)
13
21
  type: number
14
22
  default: 0
23
+ size:
24
+ description: >-
25
+ Override the variant's font-size on the body ladder. Maps to
26
+ --a-body-sm / --a-body-md / --a-body-lg. Permissive: unknown values
27
+ are no-ops (variant size wins). Added v0.6.18 (FB-10).
28
+ type: string
29
+ enum: ["sm", "md", "lg"]
30
+ default: ""
31
+ reflect: true
15
32
  strong:
16
33
  description: When true, applies stronger emphasis (heavier weight + accent color). Styled via :scope[strong] in text.css. Use instead of variant=heading when you want a single emphasized word inline in body copy.
17
34
  type: boolean
18
35
  default: false
19
36
  reflect: true
37
+ text-align:
38
+ description: >-
39
+ Override text alignment. Note: text-ui defaults to display:inline,
40
+ so this only takes effect when text-ui is block-like (wrapping or
41
+ parent display:block/grid). Added v0.6.18 (FB-10).
42
+ type: string
43
+ enum: ["start", "center", "end", "justify"]
44
+ default: ""
45
+ reflect: true
20
46
  truncate:
21
47
  description: Single-line truncation with ellipsis. Ignored when `lines` is set.
22
48
  type: boolean
23
49
  default: false
24
50
  reflect: true
51
+ weight:
52
+ description: >-
53
+ Override the variant's font-weight. Maps to --a-weight /
54
+ --a-weight-medium / --a-weight-semibold / --a-weight-bold.
55
+ Permissive: unknown values are no-ops (variant weight wins). Added
56
+ v0.6.18 (FB-10).
57
+ type: string
58
+ enum: ["regular", "medium", "semibold", "bold"]
59
+ default: ""
60
+ reflect: true
25
61
  textContent:
26
62
  description: Display text content. The main payload field for Text components extracted from HTML.
27
63
  type: string
@@ -272,11 +272,17 @@ export class UIToggleScheme extends UIElement {
272
272
  #writeTarget(scheme) {
273
273
  const t = this.#resolveTarget();
274
274
  t.style.setProperty('color-scheme', scheme);
275
+ // FEEDBACK-14: also write the [data-scheme] attribute. styles/themes.css
276
+ // selects on [data-scheme="dark"] etc., so consumer CSS patterned after
277
+ // themes.css needs the attribute — the inline color-scheme style alone
278
+ // satisfies AdiaUI's light-dark() tokens but not [data-scheme]-scoped CSS.
279
+ t.setAttribute('data-scheme', scheme);
275
280
  }
276
281
 
277
282
  #clearTargetOverride() {
278
283
  const t = this.#resolveTarget();
279
284
  t.style.removeProperty('color-scheme');
285
+ t.removeAttribute('data-scheme'); // FEEDBACK-14 — paired with #writeTarget
280
286
  if (this.persist) {
281
287
  try { localStorage.removeItem(`${this.storagePrefix}scheme`); } catch {}
282
288
  }
@@ -149,7 +149,13 @@ tokens:
149
149
  --toggle-scheme-icon-transition:
150
150
  description: Duration + easing for icon-color transition when scheme flips.
151
151
  a2ui:
152
- rules: []
152
+ rules:
153
+ - >-
154
+ Place toggle-scheme-ui in the shell topbar's trailing action cluster
155
+ — slot="action" inside <admin-topbar slot="header"> of
156
+ <admin-content>. It is a persistent, app-wide preference control;
157
+ never put it in a sidebar footer / <admin-statusbar>, which hosts
158
+ user-account items only.
153
159
  anti_patterns: []
154
160
  examples:
155
161
  - name: header-action
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.6.16",
3
+ "version": "0.6.18",
4
4
  "description": "AdiaUI web components \u2014 vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",