@adia-ai/web-components 0.5.5 → 0.5.7

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 (47) hide show
  1. package/components/accordion/accordion-item.a2ui.json +19 -2
  2. package/components/accordion/accordion-item.yaml +20 -0
  3. package/components/accordion/accordion.a2ui.json +1 -1
  4. package/components/accordion/accordion.yaml +1 -1
  5. package/components/accordion/class.js +5 -0
  6. package/components/agent-artifact/agent-artifact.yaml +3 -0
  7. package/components/agent-artifact/class.js +5 -0
  8. package/components/agent-feedback-bar/class.js +9 -3
  9. package/components/calendar-picker/calendar-picker.d.ts +10 -0
  10. package/components/code/code.d.ts +8 -0
  11. package/components/color-picker/class.js +42 -4
  12. package/components/color-picker/color-picker.test.js +96 -0
  13. package/components/color-picker/color-picker.yaml +2 -0
  14. package/components/input/class.js +101 -1
  15. package/components/input/input.a2ui.json +2 -2
  16. package/components/input/input.css +57 -0
  17. package/components/input/input.d.ts +2 -0
  18. package/components/input/input.test.js +123 -0
  19. package/components/input/input.yaml +15 -2
  20. package/components/segmented/segmented.d.ts +3 -3
  21. package/components/select/select.d.ts +5 -3
  22. package/components/slider/slider.d.ts +4 -0
  23. package/components/switch/switch.d.ts +5 -3
  24. package/components/table/class.js +9 -1
  25. package/components/table/table.yaml +4 -0
  26. package/components/table-toolbar/class.js +5 -0
  27. package/components/table-toolbar/table-toolbar.yaml +4 -0
  28. package/components/text/text.a2ui.json +1 -8
  29. package/components/text/text.css +13 -0
  30. package/components/text/text.d.ts +1 -1
  31. package/components/text/text.test.js +106 -0
  32. package/components/text/text.yaml +0 -7
  33. package/components/timeline/class.js +5 -0
  34. package/components/timeline/timeline.yaml +3 -0
  35. package/components/toast/toast.d.ts +35 -0
  36. package/components/toggle-scheme/class.js +31 -0
  37. package/components/toggle-scheme/toggle-scheme.test.js +110 -0
  38. package/components/upload/upload.d.ts +6 -0
  39. package/core/anchor.d.ts +71 -0
  40. package/core/controller.d.ts +171 -0
  41. package/core/markdown.d.ts +26 -0
  42. package/core/polyfills.d.ts +31 -0
  43. package/core/provider.d.ts +82 -0
  44. package/core/streams-bridge.d.ts +78 -0
  45. package/core/transport.d.ts +78 -0
  46. package/package.json +4 -2
  47. package/styles/components.css +2 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * input-ui — focused unit tests for the §199 (v0.5.7) leading/trailing
3
+ * affordance-slot wiring.
4
+ *
5
+ * Pre-§199 the yaml declared `slot="leading"` + `slot="trailing"` since
6
+ * v1 but nothing rendered them — consumer-authored buttons sat OUTSIDE
7
+ * the field chrome. §199 closes the schema-vs-impl gap by moving
8
+ * consumer-supplied affordance nodes into [slot="field"] at the right
9
+ * insertion points on connected().
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach } from 'vitest';
13
+ import '../../core/element.js';
14
+ import './input.js';
15
+
16
+ const tick = () => new Promise((r) => queueMicrotask(r));
17
+
18
+ function mount(html) {
19
+ const wrap = document.createElement('div');
20
+ wrap.innerHTML = html;
21
+ document.body.appendChild(wrap);
22
+ return wrap.firstElementChild;
23
+ }
24
+
25
+ describe('input-ui — §199 leading/trailing affordance slots', () => {
26
+ beforeEach(() => { document.body.innerHTML = ''; });
27
+
28
+ it('renders a baseline input without any affordances (no regression)', () => {
29
+ const el = mount('<input-ui value="hello"></input-ui>');
30
+ const field = el.querySelector(':scope > [slot="field"]');
31
+ expect(field).not.toBeNull();
32
+ expect(field.querySelector('[slot="text"]')).not.toBeNull();
33
+ expect(field.querySelector('[slot="leading"]')).toBeNull();
34
+ expect(field.querySelector('[slot="trailing"]')).toBeNull();
35
+ });
36
+
37
+ it('moves consumer [slot="trailing"] into [slot="field"]', () => {
38
+ const el = mount(`
39
+ <input-ui value="Theme 1" suffix="Light">
40
+ <button slot="trailing" data-test="open" aria-label="Open">↗</button>
41
+ </input-ui>
42
+ `);
43
+ const field = el.querySelector(':scope > [slot="field"]');
44
+ const trailing = field.querySelector(':scope > [slot="trailing"]');
45
+ expect(trailing).not.toBeNull();
46
+ expect(trailing.dataset.test).toBe('open');
47
+ // No stray trailing nodes left as direct children of <input-ui>
48
+ expect(el.querySelector(':scope > [slot="trailing"]')).toBeNull();
49
+ });
50
+
51
+ it('moves consumer [slot="leading"] into [slot="field"]', () => {
52
+ const el = mount(`
53
+ <input-ui value="https://...">
54
+ <button slot="leading" data-test="link" aria-label="Open link">↗</button>
55
+ </input-ui>
56
+ `);
57
+ const field = el.querySelector(':scope > [slot="field"]');
58
+ const leading = field.querySelector(':scope > [slot="leading"]');
59
+ expect(leading).not.toBeNull();
60
+ expect(leading.dataset.test).toBe('link');
61
+ expect(el.querySelector(':scope > [slot="leading"]')).toBeNull();
62
+ });
63
+
64
+ it('positions trailing after suffix and before controls (number mode)', () => {
65
+ const el = mount(`
66
+ <input-ui type="number" value="42" suffix="kg">
67
+ <button slot="trailing" data-test="reset">↺</button>
68
+ </input-ui>
69
+ `);
70
+ const field = el.querySelector(':scope > [slot="field"]');
71
+ const children = Array.from(field.children);
72
+ const suffixIdx = children.findIndex((c) => c.getAttribute('slot') === 'suffix');
73
+ const trailingIdx = children.findIndex((c) => c.getAttribute('slot') === 'trailing');
74
+ const controlsIdx = children.findIndex((c) => c.getAttribute('slot') === 'controls');
75
+ expect(suffixIdx).toBeGreaterThanOrEqual(0);
76
+ expect(trailingIdx).toBeGreaterThanOrEqual(0);
77
+ expect(controlsIdx).toBeGreaterThanOrEqual(0);
78
+ expect(suffixIdx).toBeLessThan(trailingIdx);
79
+ expect(trailingIdx).toBeLessThan(controlsIdx);
80
+ });
81
+
82
+ it('positions leading after label (when label present)', () => {
83
+ const el = mount(`
84
+ <input-ui label="Theme" value="Theme 1">
85
+ <button slot="leading" data-test="picker">🎨</button>
86
+ </input-ui>
87
+ `);
88
+ const field = el.querySelector(':scope > [slot="field"]');
89
+ const children = Array.from(field.children);
90
+ const labelIdx = children.findIndex((c) => c.getAttribute('slot') === 'label');
91
+ const leadingIdx = children.findIndex((c) => c.getAttribute('slot') === 'leading');
92
+ const textIdx = children.findIndex((c) => c.getAttribute('slot') === 'text');
93
+ expect(labelIdx).toBeGreaterThanOrEqual(0);
94
+ expect(leadingIdx).toBeGreaterThanOrEqual(0);
95
+ expect(textIdx).toBeGreaterThanOrEqual(0);
96
+ expect(labelIdx).toBeLessThan(leadingIdx);
97
+ expect(leadingIdx).toBeLessThan(textIdx);
98
+ });
99
+
100
+ it('supports multiple trailing nodes in author order', () => {
101
+ const el = mount(`
102
+ <input-ui value="hello">
103
+ <button slot="trailing" data-test="a">A</button>
104
+ <button slot="trailing" data-test="b">B</button>
105
+ </input-ui>
106
+ `);
107
+ const field = el.querySelector(':scope > [slot="field"]');
108
+ const trailings = Array.from(field.querySelectorAll(':scope > [slot="trailing"]'));
109
+ expect(trailings.map((n) => n.dataset.test)).toEqual(['a', 'b']);
110
+ });
111
+
112
+ it('preserves contenteditable surface alongside affordances', () => {
113
+ const el = mount(`
114
+ <input-ui value="Theme 1" suffix="Light">
115
+ <button slot="trailing" data-test="open">↗</button>
116
+ </input-ui>
117
+ `);
118
+ const text = el.querySelector('[slot="text"]');
119
+ expect(text).not.toBeNull();
120
+ expect(text.getAttribute('contenteditable')).toBe('plaintext-only');
121
+ expect(text.textContent).toBe('Theme 1');
122
+ });
123
+ });
@@ -153,11 +153,24 @@ events:
153
153
  description: Fired when Enter commits the value.
154
154
  slots:
155
155
  leading:
156
- description: Leading icon slot, sized to --content-height. Collapses text inline padding when present.
156
+ description: |-
157
+ Leading affordance slot, inside the field chrome, before the
158
+ value. Sized to chrome height. Author `<button-ui slot="leading"
159
+ icon="..." variant="ghost" size="sm">` (or any inline element)
160
+ for inline actions before the value — e.g. a link-icon button
161
+ next to a URL input. Wired §199 v0.5.7.
157
162
  text:
158
163
  description: Contenteditable text surface for user input
159
164
  trailing:
160
- description: Trailing icon slot, sized to --content-height. Collapses text inline padding when present.
165
+ description: |-
166
+ Trailing affordance slot, inside the field chrome, after the
167
+ value (and after [slot="suffix"] if present). Sized to chrome
168
+ height. Author `<button-ui slot="trailing" icon="..."
169
+ variant="ghost" size="sm" aria-label="...">` for inline actions
170
+ like copy / clear / open-in-modal. For trailing text/icon
171
+ labels (e.g. "Light" in a theme picker), use the `suffix` prop
172
+ instead — affordance slots are for interactive buttons, not
173
+ text. Wired §199 v0.5.7.
161
174
  states:
162
175
  - name: idle
163
176
  description: Default, ready for interaction.
@@ -6,10 +6,10 @@
6
6
 
7
7
  import { UIFormElement } from '../../core/form.js';
8
8
 
9
- export interface SegmentedChangeEventDetail {
10
- value: string;
9
+ export interface SegmentedChangeEventDetail<V extends string = string> {
10
+ value: V;
11
11
  }
12
- export type SegmentedChangeEvent = CustomEvent<SegmentedChangeEventDetail>;
12
+ export type SegmentedChangeEvent<V extends string = string> = CustomEvent<SegmentedChangeEventDetail<V>>;
13
13
 
14
14
  export class UISegmented extends UIFormElement {
15
15
  /** Selected segment's value. */
@@ -16,10 +16,10 @@ export interface SelectOption {
16
16
  divider?: boolean;
17
17
  }
18
18
 
19
- export interface SelectChangeEventDetail {
20
- value: string;
19
+ export interface SelectChangeEventDetail<V extends string = string> {
20
+ value: V;
21
21
  }
22
- export type SelectChangeEvent = CustomEvent<SelectChangeEventDetail>;
22
+ export type SelectChangeEvent<V extends string = string> = CustomEvent<SelectChangeEventDetail<V>>;
23
23
 
24
24
  export interface SelectActionEventDetail {
25
25
  action: string;
@@ -40,6 +40,8 @@ export class UISelect extends UIFormElement {
40
40
  /** Allow values not in the option list (combobox mode). */
41
41
  freeText: boolean;
42
42
  divider: boolean;
43
+ /** §207 (v0.5.7): hint text below the field, wired to aria-describedby. */
44
+ hint: string;
43
45
 
44
46
  /**
45
47
  * Dynamic option list. Setting `.options = [...]` stamps option elements at
@@ -20,6 +20,10 @@ export class UISlider extends UIFormElement {
20
20
  step: number;
21
21
  label: string;
22
22
  suffix: string;
23
+ /** §184 (v0.5.5, FEEDBACK-08 §4): debounce `input` event by this many ms. 0 = no throttle. */
24
+ throttle: number;
25
+ /** §184 (v0.5.5, FEEDBACK-08 §4): hint text rendered below the track, wired to aria-describedby. */
26
+ hint: string;
23
27
 
24
28
  addEventListener<K extends keyof HTMLElementEventMap>(
25
29
  type: K,
@@ -6,13 +6,13 @@
6
6
 
7
7
  import { UIFormElement } from '../../core/form.js';
8
8
 
9
- export interface SwitchChangeEventDetail {
9
+ export interface SwitchChangeEventDetail<V extends string = string> {
10
10
  /** Submitted value (defaults to `"on"` when checked). */
11
- value: string;
11
+ value: V;
12
12
  /** Current checked state. */
13
13
  checked: boolean;
14
14
  }
15
- export type SwitchChangeEvent = CustomEvent<SwitchChangeEventDetail>;
15
+ export type SwitchChangeEvent<V extends string = string> = CustomEvent<SwitchChangeEventDetail<V>>;
16
16
 
17
17
  export class UISwitch extends UIFormElement {
18
18
  /** Checked state — reflected, toggles on click. */
@@ -20,6 +20,8 @@ export class UISwitch extends UIFormElement {
20
20
  label: string;
21
21
  /** Size — `sm` / `md` / `lg`. */
22
22
  size: '' | 'sm' | 'md' | 'lg';
23
+ /** §207 (v0.5.7): hint text rendered below the switch, wired to aria-describedby. */
24
+ hint: string;
23
25
 
24
26
  addEventListener<K extends keyof HTMLElementEventMap>(
25
27
  type: K,
@@ -95,7 +95,15 @@ export class UITable extends UIElement {
95
95
  // consumer markup). Aggregated by installIconLoadersForRegistered()
96
96
  // across all defined elements. Audited by check-required-icons.mjs
97
97
  // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
98
- static requiredIcons = ['caret-right', 'caret-up-down', 'table'];
98
+ static requiredIcons = [
99
+ 'caret-right',
100
+ 'caret-up-down',
101
+ 'table',
102
+ 'arrow-up',
103
+ 'arrow-down',
104
+ 'funnel-simple',
105
+ 'funnel-simple-fill',
106
+ ];
99
107
 
100
108
  static properties = {
101
109
  sortable: { type: Boolean, default: false, reflect: true },
@@ -200,6 +200,10 @@ requiredIcons:
200
200
  - caret-right
201
201
  - caret-up-down
202
202
  - table
203
+ - arrow-up
204
+ - arrow-down
205
+ - funnel-simple
206
+ - funnel-simple-fill
203
207
  a2ui:
204
208
  rules: []
205
209
  anti_patterns: []
@@ -124,6 +124,11 @@ export class UITableToolbar extends UIElement {
124
124
  variant: { type: String, default: 'default', reflect: true },
125
125
  };
126
126
 
127
+ // §205 (v0.5.7): dynamic sort-indicator icons (class.js:576 — nested ternary
128
+ // `dir === 'asc' ? 'arrow-up' : dir === 'desc' ? 'arrow-down' : 'caret-up-down'`).
129
+ // Per FEEDBACK-16 §1 + §209 slot-11 ternary-walker discovery.
130
+ static requiredIcons = ['arrow-up', 'arrow-down', 'caret-up-down'];
131
+
127
132
  static template = () => null;
128
133
 
129
134
  #target = null;
@@ -113,6 +113,10 @@ tokens:
113
113
  description: Title font size
114
114
  --table-toolbar-title-weight:
115
115
  description: Title font weight
116
+ requiredIcons:
117
+ - arrow-up
118
+ - arrow-down
119
+ - caret-up-down
116
120
  a2ui:
117
121
  rules: []
118
122
  anti_patterns: []
@@ -41,7 +41,6 @@
41
41
  "enum": [
42
42
  "body",
43
43
  "heading",
44
- "subheading",
45
44
  "title",
46
45
  "subsection",
47
46
  "display",
@@ -51,13 +50,7 @@
51
50
  "deck",
52
51
  "section",
53
52
  "metric",
54
- "code",
55
- "h1",
56
- "h2",
57
- "h3",
58
- "h4",
59
- "h5",
60
- "h6"
53
+ "code"
61
54
  ],
62
55
  "default": "body"
63
56
  }
@@ -48,6 +48,19 @@
48
48
  :scope[variant="kicker"] { --text-family: var(--a-kicker-family); --text-weight: var(--a-kicker-weight); --text-size: var(--a-kicker-size); --text-leading: var(--a-kicker-leading); --text-tracking: var(--a-kicker-tracking); --text-case: uppercase; --text-color: var(--a-kicker-color); }
49
49
  :scope[variant="code"] { --text-family: var(--a-code-family); --text-weight: var(--a-code-weight); --text-size: var(--a-code-size); --text-leading: var(--a-code-leading); --text-tracking: var(--a-code-tracking); --text-case: var(--a-code-case); --text-color: var(--a-code-color); }
50
50
 
51
+ /* §210 (v0.5.7, FEEDBACK-17 §1): three token-backed variants whose
52
+ `--a-<role>-{family,weight,leading,tracking,case,color,size}` tokens
53
+ ship in `styles/typography.css` but had no matching :scope rule —
54
+ authoring `<text-ui variant="subsection|deck|metric">` per the
55
+ documented yaml enum silently rendered as `body` defaults. The
56
+ `subheading` + `h1`–`h6` enum entries are deliberately omitted from
57
+ this batch: their tokens don't ship anywhere (verified in this same
58
+ arc), so the enum entries are retired from text.yaml / text.d.ts /
59
+ text.a2ui.json. */
60
+ :scope[variant="subsection"] { --text-family: var(--a-subsection-family); --text-weight: var(--a-subsection-weight); --text-size: var(--a-subsection-size); --text-leading: var(--a-subsection-leading); --text-tracking: var(--a-subsection-tracking); --text-case: var(--a-subsection-case); --text-color: var(--a-subsection-color); }
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
+ :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
+
51
64
  /* ── Truncation (single-line) ── */
52
65
  :scope[truncate] {
53
66
  overflow: hidden;
@@ -22,5 +22,5 @@ export class UIText extends UIElement {
22
22
  /** Single-line truncation with ellipsis. Ignored when `lines` is set. */
23
23
  truncate: boolean;
24
24
  /** Typography variant — sets role tokens (size/weight/tracking/color). */
25
- variant: 'body' | 'heading' | 'subheading' | 'title' | 'subsection' | 'display' | 'caption' | 'label' | 'kicker' | 'deck' | 'section' | 'metric' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
25
+ variant: 'body' | 'heading' | 'title' | 'subsection' | 'display' | 'caption' | 'label' | 'kicker' | 'deck' | 'section' | 'metric' | 'code';
26
26
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * text-ui variant rendering tests — §210 (v0.5.7, FEEDBACK-17 §1).
3
+ *
4
+ * Verifies the 12 documented enum values in `text.yaml`/`text.d.ts`/
5
+ * `text.a2ui.json` all render distinctly per the `:scope[variant=…]`
6
+ * rules in `text.css`. Pre-§210 the three token-backed variants
7
+ * `subsection` / `deck` / `metric` silently rendered as `body`
8
+ * defaults because the matching `:scope` rules were missing — the
9
+ * yaml + d.ts advertised them but the CSS didn't consume the tokens.
10
+ *
11
+ * Plus a guard: the 6 phantom enum entries removed in §210
12
+ * (`subheading`, `h1`-`h6`) must NOT be in the .d.ts type union or
13
+ * the a2ui.json enum — they had no shipped tokens and rendering them
14
+ * was always body-defaults.
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach } from 'vitest';
18
+ import { readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, resolve } from 'node:path';
21
+ import '../../core/element.js';
22
+ import './text.js';
23
+
24
+ const tick = () => new Promise((r) => queueMicrotask(r));
25
+
26
+ function mount(html) {
27
+ const wrap = document.createElement('div');
28
+ wrap.innerHTML = html;
29
+ document.body.appendChild(wrap);
30
+ return wrap.firstElementChild;
31
+ }
32
+
33
+ // jsdom doesn't evaluate @scope rules in `getComputedStyle()`. We
34
+ // validate the CSS by reading the rule out of text.css text and
35
+ // asserting the variant-specific properties are present — coarse
36
+ // but catches the §210 regression class (rule missing entirely).
37
+ const HERE = dirname(fileURLToPath(import.meta.url));
38
+ const TEXT_CSS = readFileSync(resolve(HERE, 'text.css'), 'utf8');
39
+ const TEXT_DTS = readFileSync(resolve(HERE, 'text.d.ts'), 'utf8');
40
+ const TEXT_A2UI = JSON.parse(readFileSync(resolve(HERE, 'text.a2ui.json'), 'utf8'));
41
+
42
+ describe('text-ui §210 — variant enum vs CSS rule completeness', () => {
43
+ beforeEach(() => { document.body.innerHTML = ''; });
44
+
45
+ // ── Mounting smoke — every documented variant constructs without crash ──
46
+ const documentedVariants = [
47
+ 'body', 'heading', 'title', 'subsection', 'display', 'caption',
48
+ 'label', 'kicker', 'deck', 'section', 'metric', 'code',
49
+ ];
50
+
51
+ it.each(documentedVariants)('mounts <text-ui variant="%s"> without error', async (variant) => {
52
+ const t = mount(`<text-ui variant="${variant}">Sample</text-ui>`);
53
+ await tick();
54
+ expect(t).toBeDefined();
55
+ expect(t.getAttribute('variant')).toBe(variant);
56
+ });
57
+
58
+ // ── CSS-side: every documented variant has a :scope rule ──
59
+ it.each(documentedVariants)('text.css ships a :scope[variant="%s"] rule', (variant) => {
60
+ expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[variant="${variant}"\\]`));
61
+ });
62
+
63
+ // ── Three token-backed variants restored in §210 ──
64
+ it.each(['subsection', 'deck', 'metric'])(
65
+ 'text.css :scope[variant="%s"] consumes role tokens (not body defaults)',
66
+ (variant) => {
67
+ // The rule must reference the role-specific token, not fall through
68
+ // to var(--a-body-*) defaults.
69
+ const ruleMatch = TEXT_CSS.match(
70
+ new RegExp(`:scope\\[variant="${variant}"\\]\\s*\\{([^}]+)\\}`)
71
+ );
72
+ expect(ruleMatch, `rule missing for variant="${variant}"`).toBeTruthy();
73
+ const rule = ruleMatch[1];
74
+ expect(rule).toMatch(new RegExp(`var\\(--a-${variant}-family\\)`));
75
+ expect(rule).toMatch(new RegExp(`var\\(--a-${variant}-weight\\)`));
76
+ expect(rule).toMatch(new RegExp(`var\\(--a-${variant}-size\\)`));
77
+ }
78
+ );
79
+
80
+ // ── Type-side: 6 phantom entries removed in §210 ──
81
+ const removedPhantoms = ['subheading', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
82
+
83
+ it.each(removedPhantoms)('text.d.ts variant union does NOT contain phantom "%s"', (variant) => {
84
+ expect(TEXT_DTS).not.toMatch(new RegExp(`'${variant}'`));
85
+ });
86
+
87
+ it.each(removedPhantoms)('text.a2ui.json variant enum does NOT contain phantom "%s"', (variant) => {
88
+ expect(TEXT_A2UI.properties.variant.enum).not.toContain(variant);
89
+ });
90
+
91
+ // ── a2ui.json enum and .d.ts union and CSS rules are mutually consistent ──
92
+ it('a2ui.json variant enum matches .d.ts union and CSS rules 1:1', () => {
93
+ const a2uiVariants = TEXT_A2UI.properties.variant.enum;
94
+ const dtsUnionMatch = TEXT_DTS.match(/variant:\s*((?:'[^']+'\s*\|?\s*)+);/);
95
+ expect(dtsUnionMatch).toBeTruthy();
96
+ const dtsVariants = [...dtsUnionMatch[1].matchAll(/'([^']+)'/g)].map(m => m[1]);
97
+
98
+ expect(a2uiVariants.sort()).toEqual(dtsVariants.sort());
99
+ expect(a2uiVariants.sort()).toEqual([...documentedVariants].sort());
100
+
101
+ // Every variant has a CSS rule.
102
+ for (const v of a2uiVariants) {
103
+ expect(TEXT_CSS).toMatch(new RegExp(`:scope\\[variant="${v}"\\]`));
104
+ }
105
+ });
106
+ });
@@ -34,7 +34,6 @@ props:
34
34
  enum:
35
35
  - body
36
36
  - heading
37
- - subheading
38
37
  - title
39
38
  - subsection
40
39
  - display
@@ -45,12 +44,6 @@ props:
45
44
  - section
46
45
  - metric
47
46
  - code
48
- - h1
49
- - h2
50
- - h3
51
- - h4
52
- - h5
53
- - h6
54
47
  events: {}
55
48
  slots: {}
56
49
  states:
@@ -87,6 +87,11 @@ export class UITimelineItem extends UIElement {
87
87
  spinner: { type: Boolean, default: false, reflect: true },
88
88
  };
89
89
 
90
+ // §205 (v0.5.7): dynamic chevron icons stamped on expanded-state ternary
91
+ // (class.js:167). Per FEEDBACK-16 §1 + §209 slot-11 ternary-walker discovery.
92
+ // Note: `this.icon` consumer-supplied — not declared here.
93
+ static requiredIcons = ['caret-down', 'caret-right'];
94
+
90
95
  static template = () => null;
91
96
 
92
97
  #outcomes = [];
@@ -40,6 +40,9 @@ states:
40
40
  description: Default, ready for interaction.
41
41
  traits: []
42
42
  tokens: {}
43
+ requiredIcons:
44
+ - caret-down
45
+ - caret-right
43
46
  a2ui:
44
47
  rules: []
45
48
  anti_patterns: []
@@ -12,6 +12,28 @@
12
12
 
13
13
  import { UIElement } from '../../core/element.js';
14
14
 
15
+ /** Options accepted by `UIToast.show(opts)` — the imperative one-shot path. */
16
+ export interface UIToastShowOptions {
17
+ /** Toast message text. */
18
+ text?: string;
19
+ /** Semantic variant. Legacy alias `"error"` is auto-mapped to `"danger"`. */
20
+ variant?: 'default' | 'info' | 'success' | 'warning' | 'danger' | 'primary' | 'muted' | 'neutral' | 'error';
21
+ /** Auto-dismiss time in milliseconds. 0 disables auto-dismiss. Default `4000`. */
22
+ duration?: number;
23
+ /** Screen position. Default `'bottom-right'`. */
24
+ position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
25
+ }
26
+
27
+ /** Returned by `UIToast.show()` — imperative handle for dismiss / update. */
28
+ export interface UIToastFeedHandle {
29
+ /** Stable id assigned by `<feed-ui>`. `null` if the toast couldn't be posted. */
30
+ id: string | null;
31
+ /** Dismiss the toast programmatically. */
32
+ dismiss(): void;
33
+ /** Mutate the toast's content in-place (e.g. promote from "loading" to "done"). */
34
+ update(patch: Partial<UIToastShowOptions>): void;
35
+ }
36
+
15
37
  export class UIToast extends UIElement {
16
38
  /** Auto-dismiss time in milliseconds. 0 disables auto-dismiss. */
17
39
  duration: number;
@@ -21,4 +43,17 @@ export class UIToast extends UIElement {
21
43
  text: string;
22
44
  /** Semantic variant — `default | info | success | warning | danger`. `primary` and `muted` are style hints; canonical "neutral but interesting" tone is `info`. */
23
45
  variant: 'default' | 'info' | 'success' | 'warning' | 'danger' | 'primary' | 'muted' | 'neutral';
46
+
47
+ /**
48
+ * Post a one-shot toast through the shared `<feed-ui>` host. Imperative
49
+ * alternative to declarative `<toast-ui>`. Returns a `UIToastFeedHandle`
50
+ * for programmatic dismiss / update.
51
+ *
52
+ * Legacy alias `variant: 'error'` is auto-mapped to `variant: 'danger'`.
53
+ *
54
+ * @example
55
+ * const t = UIToast.show({ text: 'Saved!', variant: 'success' });
56
+ * setTimeout(() => t.dismiss(), 1000);
57
+ */
58
+ static show(opts?: UIToastShowOptions): UIToastFeedHandle;
24
59
  }
@@ -78,6 +78,12 @@ export class UIToggleScheme extends UIElement {
78
78
  #mqlHandler = null;
79
79
  #onPress = null;
80
80
  #stamped = false;
81
+ // §200 (v0.5.7, FEEDBACK-10 §1): set true after first user-driven scheme
82
+ // mutation (button press OR programmatic setScheme/toggle). Until then,
83
+ // the `scheme` attribute is treated as "reactive consumer-driven" — any
84
+ // post-connect attribute application re-runs #initState() so the reactive
85
+ // value wins over the template-strip race.
86
+ #userTouched = false;
81
87
 
82
88
  connected() {
83
89
  if (!this.#stamped) {
@@ -87,6 +93,26 @@ export class UIToggleScheme extends UIElement {
87
93
  this.#initState();
88
94
  }
89
95
 
96
+ attributeChangedCallback(name, oldVal, newVal) {
97
+ // §200 (v0.5.7, FEEDBACK-10 §1): UIElement.attributeChangedCallback syncs
98
+ // attr → property. After it runs, if the `scheme` attribute changed AFTER
99
+ // connect AND the user hasn't yet chosen explicitly, re-run #initState()
100
+ // so the reactive consumer value wins over the template-engine's strip-
101
+ // then-restamp race. Once the user clicks the button or calls
102
+ // setScheme()/toggle() programmatically, #userTouched flips true and we
103
+ // stop auto-reinit so user choice survives subsequent re-renders.
104
+ super.attributeChangedCallback(name, oldVal, newVal);
105
+ if (
106
+ name === 'scheme' &&
107
+ this.isConnected &&
108
+ this.#stamped &&
109
+ !this.#userTouched &&
110
+ oldVal !== newVal
111
+ ) {
112
+ this.#initState();
113
+ }
114
+ }
115
+
90
116
  disconnected() {
91
117
  if (this.#button && this.#onPress) {
92
118
  this.#button.removeEventListener('press', this.#onPress);
@@ -98,6 +124,7 @@ export class UIToggleScheme extends UIElement {
98
124
 
99
125
  /** Flip between light and dark — defeats auto. */
100
126
  toggle() {
127
+ this.#userTouched = true;
101
128
  const next = this.activeScheme === DARK ? LIGHT : DARK;
102
129
  this.#apply(next, 'programmatic');
103
130
  }
@@ -106,6 +133,7 @@ export class UIToggleScheme extends UIElement {
106
133
  * @param {"light"|"dark"|"auto"} s
107
134
  */
108
135
  setScheme(s) {
136
+ this.#userTouched = true;
109
137
  if (s === AUTO) {
110
138
  this.#clearTargetOverride();
111
139
  const resolved = this.#resolvePrefersScheme();
@@ -135,6 +163,9 @@ export class UIToggleScheme extends UIElement {
135
163
  // so consumers see one semantic event, not the inner button's.
136
164
  e.stopPropagation();
137
165
  if (this.disabled) return;
166
+ // §200 (v0.5.7): mark user-touched so post-connect reactive `scheme`
167
+ // attribute writes don't override the user's explicit choice.
168
+ this.#userTouched = true;
138
169
  const next = this.activeScheme === DARK ? LIGHT : DARK;
139
170
  this.#apply(next, 'press');
140
171
  };