@adia-ai/web-components 0.5.6 → 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 (37) 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/calendar-picker/calendar-picker.d.ts +10 -0
  9. package/components/code/code.d.ts +8 -0
  10. package/components/color-picker/class.js +42 -4
  11. package/components/color-picker/color-picker.test.js +96 -0
  12. package/components/color-picker/color-picker.yaml +2 -0
  13. package/components/input/class.js +101 -1
  14. package/components/input/input.a2ui.json +2 -2
  15. package/components/input/input.css +57 -0
  16. package/components/input/input.d.ts +2 -0
  17. package/components/input/input.test.js +123 -0
  18. package/components/input/input.yaml +15 -2
  19. package/components/select/select.d.ts +2 -0
  20. package/components/slider/slider.d.ts +4 -0
  21. package/components/switch/switch.d.ts +2 -0
  22. package/components/table/class.js +9 -1
  23. package/components/table/table.yaml +4 -0
  24. package/components/table-toolbar/class.js +5 -0
  25. package/components/table-toolbar/table-toolbar.yaml +4 -0
  26. package/components/text/text.a2ui.json +1 -8
  27. package/components/text/text.css +13 -0
  28. package/components/text/text.d.ts +1 -1
  29. package/components/text/text.test.js +106 -0
  30. package/components/text/text.yaml +0 -7
  31. package/components/timeline/class.js +5 -0
  32. package/components/timeline/timeline.yaml +3 -0
  33. package/components/toggle-scheme/class.js +31 -0
  34. package/components/toggle-scheme/toggle-scheme.test.js +110 -0
  35. package/components/upload/upload.d.ts +6 -0
  36. package/package.json +4 -2
  37. package/styles/components.css +2 -0
@@ -34,12 +34,29 @@
34
34
  "anti_patterns": [],
35
35
  "category": "layout",
36
36
  "composes": [],
37
- "events": {},
37
+ "events": {
38
+ "toggle": {
39
+ "description": "Fired when the section opens or closes.",
40
+ "detail": {
41
+ "open": {
42
+ "description": "New open state.",
43
+ "type": "boolean"
44
+ }
45
+ }
46
+ }
47
+ },
38
48
  "examples": [],
39
49
  "keywords": [],
40
50
  "name": "UIAccordionItem",
41
51
  "related": [],
42
- "slots": {},
52
+ "slots": {
53
+ "action": {
54
+ "description": "§206 (v0.5.7): action buttons inside a custom header (e.g. Copy /\nDownload / settings). Children placed at `[slot=\"action\"]` (or\n`[slot=\"actions\"]`, or marked `[data-no-toggle]`) are excluded from\nthe toggle-on-click cascade — clicking them fires their own handler\nwithout also toggling the section."
55
+ },
56
+ "header": {
57
+ "description": "Custom header content. By default `[text]` renders as a plain header\nlabel, but a `[slot=\"header\"]` override lets consumers author rich\nheaders (icon + title + action buttons + caret)."
58
+ }
59
+ },
43
60
  "states": [],
44
61
  "synonyms": {},
45
62
  "tag": "accordion-item-ui",
@@ -25,3 +25,23 @@ props:
25
25
  description: Whether the section is expanded.
26
26
  type: boolean
27
27
  default: false
28
+ slots:
29
+ header:
30
+ description: |-
31
+ Custom header content. By default `[text]` renders as a plain header
32
+ label, but a `[slot="header"]` override lets consumers author rich
33
+ headers (icon + title + action buttons + caret).
34
+ action:
35
+ description: |-
36
+ §206 (v0.5.7): action buttons inside a custom header (e.g. Copy /
37
+ Download / settings). Children placed at `[slot="action"]` (or
38
+ `[slot="actions"]`, or marked `[data-no-toggle]`) are excluded from
39
+ the toggle-on-click cascade — clicking them fires their own handler
40
+ without also toggling the section.
41
+ events:
42
+ toggle:
43
+ description: Fired when the section opens or closes.
44
+ detail:
45
+ open:
46
+ type: boolean
47
+ description: New open state.
@@ -76,7 +76,7 @@
76
76
  ],
77
77
  "slots": {
78
78
  "default": {
79
- "description": "pane-ui children"
79
+ "description": "accordion-item-ui children"
80
80
  }
81
81
  },
82
82
  "states": [
@@ -25,7 +25,7 @@ events:
25
25
  description: New open state of the panel.
26
26
  slots:
27
27
  default:
28
- description: pane-ui children
28
+ description: accordion-item-ui children
29
29
  states:
30
30
  - name: idle
31
31
  description: Default, ready for interaction.
@@ -128,6 +128,11 @@ export class UIAccordionItem extends UIElement {
128
128
  #onClick = (e) => {
129
129
  const header = this.querySelector('[slot="header"]');
130
130
  if (!header || !header.contains(e.target)) return;
131
+ // FEEDBACK-16 §2 (v0.5.7 §206): skip toggle when click originates inside an
132
+ // action slot or an opt-out marker. Lets consumers author action buttons in
133
+ // custom headers without bubbling-toggle UX. Slot vocabulary matches
134
+ // drawer-ui + pane-ui.
135
+ if (e.target.closest('[slot="action"], [slot="actions"], [data-no-toggle]')) return;
131
136
 
132
137
  this.open = !this.open;
133
138
  this.dispatchEvent(new CustomEvent('toggle', {
@@ -52,6 +52,9 @@ states:
52
52
  description: Default, ready for interaction.
53
53
  traits: []
54
54
  tokens: {}
55
+ requiredIcons:
56
+ - caret-right
57
+ - caret-down
55
58
  a2ui:
56
59
  rules: []
57
60
  anti_patterns: []
@@ -58,6 +58,11 @@ export class UIAgentArtifact extends UIElement {
58
58
  tone: { type: String, default: 'neutral', reflect: true },
59
59
  };
60
60
 
61
+ // §205 (v0.5.7): dynamic chevron icons stamped on collapse/expand state
62
+ // transition (class.js:119+188). Per FEEDBACK-16 §1 + §209 slot-11 ternary-
63
+ // walker discovery. Note: `this.icon` consumer-supplied — not declared here.
64
+ static requiredIcons = ['caret-right', 'caret-down'];
65
+
61
66
  static template = () => null;
62
67
 
63
68
  #headerEl = null;
@@ -17,6 +17,16 @@ export class UICalendarPicker extends UIFormElement {
17
17
  value: string;
18
18
  /** Open/closed reflected state. */
19
19
  open: boolean;
20
+ /** §207 (v0.5.7): label rendered above the trigger. */
21
+ label: string;
22
+ /** §207 (v0.5.7): placeholder text when no date is selected. */
23
+ placeholder: string;
24
+ /** §207 (v0.5.7): display format for the trigger label (e.g. `"YYYY-MM-DD"`). */
25
+ format: string;
26
+ /** §207 (v0.5.7): earliest selectable ISO-date; `null` to disable. */
27
+ min: string | null;
28
+ /** §207 (v0.5.7): latest selectable ISO-date; `null` to disable. */
29
+ max: string | null;
20
30
 
21
31
  addEventListener<K extends keyof HTMLElementEventMap>(
22
32
  type: K,
@@ -56,6 +56,14 @@ export class UICode extends UIFormElement {
56
56
  /** Drop chrome (border, background) for inline composition. */
57
57
  bare: boolean;
58
58
  placeholder: string;
59
+ /** §207 (v0.5.7): native form-element name for `<form>` submission. */
60
+ name: string;
61
+ /** §207 (v0.5.7): form-validity `required` flag. */
62
+ required: boolean;
63
+ /** §207 (v0.5.7): native `disabled` flag — suppresses input + form submission. */
64
+ disabled: boolean;
65
+ /** §207 (v0.5.7): readonly mode — user cannot type but value is form-submitted. */
66
+ readonly: boolean;
59
67
 
60
68
  addEventListener<K extends keyof HTMLElementEventMap>(
61
69
  type: K,
@@ -127,7 +127,13 @@ export class UIColorPicker extends UIFormElement {
127
127
  // consumer markup). Aggregated by installIconLoadersForRegistered()
128
128
  // across all defined elements. Audited by check-required-icons.mjs
129
129
  // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
130
- static requiredIcons = ['copy'];
130
+ static requiredIcons = ['copy', 'check', 'warning'];
131
+
132
+ // §201 (v0.5.7): once-per-element warn dedup for #parseValue malformed
133
+ // input. Matches the v0.5.5 §184 §8 button-ui icon-only safety-net pattern
134
+ // + v0.5.7 §215 select-ui parseOptions pattern. Per FEEDBACK-13 §1 +
135
+ // FEEDBACK-14 §2 (co-credited).
136
+ static #warnedBadParse = new WeakSet();
131
137
 
132
138
  static properties = {
133
139
  ...UIFormElement.properties,
@@ -418,9 +424,41 @@ export class UIColorPicker extends UIFormElement {
418
424
  if (val.startsWith('#')) {
419
425
  const o = hexToOklch(val);
420
426
  this.#L = o.L; this.#C = o.C; this.#H = o.H;
421
- } else if (val.startsWith('oklch(')) {
422
- const m = val.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
423
- if (m) { this.#L = +m[1]; this.#C = +m[2]; this.#H = +m[3]; }
427
+ return;
428
+ }
429
+ if (val.startsWith('oklch(')) {
430
+ // §201 (v0.5.7, FEEDBACK-13 §1 + FEEDBACK-14 §2): accept floats, percent
431
+ // on L, NaN (culori chromaless convention), and 'none' (CSS Color L4
432
+ // powerless-component syntax). Channel-level normalization: NaN/none → 0;
433
+ // percent on L → divide by 100. Per the CSS Color L4 "powerless" cascade
434
+ // (https://www.w3.org/TR/css-color-4/#powerless), zeroing the hue channel
435
+ // is the spec-defined resolution when chroma is 0 or near-0.
436
+ const m = val.match(
437
+ /oklch\(\s*([\d.]+%?|NaN|none)\s+([\d.]+%?|NaN|none)\s+([\d.]+|NaN|none)/i,
438
+ );
439
+ if (m) {
440
+ const parseChan = (s, isL) => {
441
+ if (s === 'none' || /^NaN$/i.test(s)) return 0;
442
+ if (isL && s.endsWith('%')) return +s.slice(0, -1) / 100;
443
+ if (s.endsWith('%')) return +s.slice(0, -1) / 100;
444
+ return +s;
445
+ };
446
+ this.#L = parseChan(m[1], true);
447
+ this.#C = parseChan(m[2], false);
448
+ this.#H = parseChan(m[3], false);
449
+ return;
450
+ }
451
+ // No regex match — warn once per element so consumers can correct.
452
+ // WeakSet dedup matches v0.5.5 §184 §8 button-ui icon-only pattern.
453
+ if (!UIColorPicker.#warnedBadParse.has(this)) {
454
+ UIColorPicker.#warnedBadParse.add(this);
455
+ // eslint-disable-next-line no-console
456
+ console.warn(
457
+ `<color-picker-ui>: could not parse value=${JSON.stringify(val)}. ` +
458
+ `Expected #rrggbb or oklch(L C H) with numeric, NaN, 'none' (CSS L4 ` +
459
+ `powerless), or % channels. Picker is keeping prior state.`,
460
+ );
461
+ }
424
462
  }
425
463
  }
426
464
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * color-picker-ui #parseValue tests — §201 (v0.5.7, FEEDBACK-13 §1 +
3
+ * FEEDBACK-14 §2 co-credited).
4
+ *
5
+ * Verifies the relaxed `oklch(L C H)` parser accepts:
6
+ * - Numeric channels (regression — the v0.5.6 baseline)
7
+ * - `NaN` on any channel (culori chromaless convention; coerces to 0)
8
+ * - `none` on any channel (CSS Color L4 powerless syntax; coerces to 0)
9
+ * - Percent on L (e.g. `oklch(53% 0.05 240)`; divides by 100)
10
+ *
11
+ * Plus:
12
+ * - Truly malformed input emits ONE console.warn per element (WeakSet dedup),
13
+ * not one per #parseValue call.
14
+ * - Hex pass-through remains intact.
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
18
+ import '../../core/element.js';
19
+ import './color-picker.js';
20
+
21
+ const tick = () => new Promise((r) => queueMicrotask(r));
22
+
23
+ function mount(html) {
24
+ const wrap = document.createElement('div');
25
+ wrap.innerHTML = html;
26
+ document.body.appendChild(wrap);
27
+ return wrap.firstElementChild;
28
+ }
29
+
30
+ describe('color-picker-ui #parseValue (§201)', () => {
31
+ beforeEach(() => { document.body.innerHTML = ''; });
32
+
33
+ it('accepts numeric oklch (baseline regression)', async () => {
34
+ const p = mount('<color-picker-ui format="oklch" value="oklch(0.6 0.15 240)"></color-picker-ui>');
35
+ await tick();
36
+ // Read back via the public value getter — round-tripped through the parser.
37
+ expect(p.value).toMatch(/oklch/);
38
+ });
39
+
40
+ it('accepts NaN hue (culori chromaless convention)', async () => {
41
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
42
+ const p = mount('<color-picker-ui format="oklch" value="oklch(0.53 0.01 NaN)"></color-picker-ui>');
43
+ await tick();
44
+ // No warn — NaN is accepted, coerced to 0.
45
+ expect(warn).not.toHaveBeenCalled();
46
+ warn.mockRestore();
47
+ });
48
+
49
+ it('accepts `none` (CSS Color L4 powerless)', async () => {
50
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
51
+ const p = mount('<color-picker-ui format="oklch" value="oklch(0.5 0 none)"></color-picker-ui>');
52
+ await tick();
53
+ expect(warn).not.toHaveBeenCalled();
54
+ warn.mockRestore();
55
+ });
56
+
57
+ it('accepts percent on L', async () => {
58
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
59
+ const p = mount('<color-picker-ui format="oklch" value="oklch(53% 0.05 240)"></color-picker-ui>');
60
+ await tick();
61
+ expect(warn).not.toHaveBeenCalled();
62
+ warn.mockRestore();
63
+ });
64
+
65
+ it('accepts hex (regression)', async () => {
66
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
67
+ const p = mount('<color-picker-ui format="hex" value="#3b82f6"></color-picker-ui>');
68
+ await tick();
69
+ expect(warn).not.toHaveBeenCalled();
70
+ warn.mockRestore();
71
+ });
72
+
73
+ it('warns once per element on malformed input', async () => {
74
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
75
+ const p = mount('<color-picker-ui format="oklch" value="oklch(banana)"></color-picker-ui>');
76
+ await tick();
77
+ // Drive multiple re-parses via setAttribute — should NOT compound the warn count.
78
+ p.setAttribute('value', 'oklch(orange juice)');
79
+ await tick();
80
+ p.setAttribute('value', 'oklch(still broken)');
81
+ await tick();
82
+ expect(warn).toHaveBeenCalledTimes(1);
83
+ expect(warn.mock.calls[0][0]).toMatch(/could not parse value/);
84
+ warn.mockRestore();
85
+ });
86
+
87
+ it('two independent pickers each get their own warn budget', async () => {
88
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
89
+ const p1 = mount('<color-picker-ui format="oklch" value="oklch(broken-1)"></color-picker-ui>');
90
+ const p2 = mount('<color-picker-ui format="oklch" value="oklch(broken-2)"></color-picker-ui>');
91
+ await tick();
92
+ // Two separate instances — each gets their own first-warn.
93
+ expect(warn).toHaveBeenCalledTimes(2);
94
+ warn.mockRestore();
95
+ });
96
+ });
@@ -97,6 +97,8 @@ traits: []
97
97
  tokens: {}
98
98
  requiredIcons:
99
99
  - copy
100
+ - check
101
+ - warning
100
102
  a2ui:
101
103
  rules: []
102
104
  anti_patterns: []
@@ -16,13 +16,25 @@
16
16
  * Uses contenteditable for text entry, ElementInternals for form participation.
17
17
  *
18
18
  * Slots inside [slot="field"]:
19
- * prefix → label → text → suffix → controls (number mode)
19
+ * prefix → [leading] → label → text → suffix → [trailing] → controls (number mode)
20
20
  *
21
21
  * <input-ui label="Email" placeholder="you@acme.com"></input-ui>
22
22
  * <input-ui label="Email" prefix="user" placeholder="you@acme.com"></input-ui>
23
23
  * <input-ui placeholder="Search" prefix="magnifying-glass"></input-ui>
24
24
  * <input-ui prefix="@" value="kim"></input-ui>
25
25
  *
26
+ * <!-- Trailing buttons inside the input chrome (§199 v0.5.7) -->
27
+ * <input-ui value="Theme 1" suffix="Light">
28
+ * <button-ui slot="trailing" icon="arrow-square-out"
29
+ * variant="ghost" size="sm"
30
+ * aria-label="Open theme browser"></button-ui>
31
+ * </input-ui>
32
+ *
33
+ * <!-- Leading button for inline actions before the value -->
34
+ * <input-ui value="https://...">
35
+ * <button-ui slot="leading" icon="link" variant="ghost" size="sm"></button-ui>
36
+ * </input-ui>
37
+ *
26
38
  * <input-ui type="number" value="42" min="0" max="100" step="1"></input-ui>
27
39
  * <input-ui type="number" value="9.99" step="0.01" precision="2" prefix="$"></input-ui>
28
40
  *
@@ -118,9 +130,22 @@ export class UIInput extends UIFormElement {
118
130
  super.connected();
119
131
  this.setAttribute('role', this.#isNumberMode ? 'spinbutton' : 'textbox');
120
132
 
133
+ // §199 (v0.5.7): consumer-supplied leading/trailing buttons live as
134
+ // direct children of <input-ui> at author time. Capture references
135
+ // BEFORE innerHTML wipes them, so we can move them into [slot="field"]
136
+ // after the shell is built. Querying `:scope > [slot="leading|trailing"]`
137
+ // (direct-child) avoids matching anything we already moved on a prior
138
+ // re-render.
139
+ const leadingNodes = Array.from(this.querySelectorAll(':scope > [slot="leading"]'));
140
+ const trailingNodes = Array.from(this.querySelectorAll(':scope > [slot="trailing"]'));
141
+
121
142
  if (!this.querySelector('[slot="text"]')) {
122
143
  const labelId = this.label ? `input-label-${++UIInput.#labelSeq}` : '';
123
144
  this.innerHTML = this.#shellHTML(labelId);
145
+ // innerHTML wiped the consumer-supplied leading/trailing nodes; re-attach
146
+ // them into the field at the right positions. Order matches the JSDoc:
147
+ // prefix → leading → label → text → suffix → trailing → controls
148
+ this.#installAffordances(leadingNodes, trailingNodes);
124
149
  }
125
150
 
126
151
  this.#textEl = this.querySelector('[slot="text"]');
@@ -208,6 +233,81 @@ export class UIInput extends UIFormElement {
208
233
  `;
209
234
  }
210
235
 
236
+ /**
237
+ * §199 (v0.5.7) — Move consumer-supplied [slot="leading"] +
238
+ * [slot="trailing"] nodes into the field at the right insertion points.
239
+ *
240
+ * Yaml has declared `leading` + `trailing` slots since v1 ("Leading/
241
+ * Trailing icon slot, sized to --content-height. Collapses text inline
242
+ * padding when present.") but the shell never rendered them, so any
243
+ * consumer-authored `<button-ui slot="trailing">` sat OUTSIDE the
244
+ * field chrome. §199 closes the schema-vs-impl gap by:
245
+ *
246
+ * 1. Capturing the consumer's [slot="leading|trailing"] direct
247
+ * children BEFORE `innerHTML = shellHTML` wipes them.
248
+ * 2. Re-inserting each into `[slot="field"]` at the canonical
249
+ * position: leading goes right after [slot="label"] (or after
250
+ * [slot="prefix"] when no label), trailing goes right after
251
+ * [slot="suffix"] (or after [slot="text"] when no suffix), but
252
+ * always BEFORE [slot="controls"] (number-mode stepper column).
253
+ *
254
+ * Consumers typically pass `<button-ui slot="trailing">` for inline
255
+ * actions (copy / clear / open-in-modal); CSS handles the chrome
256
+ * inheritance (border continuity + size token wiring) via
257
+ * input.css §199 rules.
258
+ */
259
+ #installAffordances(leadingNodes, trailingNodes) {
260
+ const field = this.querySelector(':scope > [slot="field"]');
261
+ if (!field) return;
262
+
263
+ if (leadingNodes.length) {
264
+ // Insertion point: after [slot="label"] if present, else after
265
+ // [slot="prefix"] if present, else as the first child. Walk the
266
+ // anchor forward so author-order is preserved (first node lands
267
+ // earliest in DOM).
268
+ const start =
269
+ field.querySelector(':scope > [slot="label"]') ||
270
+ field.querySelector(':scope > [slot="prefix"]');
271
+ if (start) {
272
+ let anchor = start;
273
+ for (const node of leadingNodes) {
274
+ anchor.after(node);
275
+ anchor = node;
276
+ }
277
+ } else {
278
+ // No prefix/label — prepend in reverse so the FIRST author-order
279
+ // node ends up first after all prepends complete.
280
+ for (const node of leadingNodes.slice().reverse()) field.prepend(node);
281
+ }
282
+ }
283
+
284
+ if (trailingNodes.length) {
285
+ // Insertion point: after [slot="suffix"] if present, else after
286
+ // [slot="text"], but always before [slot="controls"] (number-mode
287
+ // stepper column). Insert in author order: walk the anchor forward
288
+ // for each node so the first author-order node lands first in DOM.
289
+ const controls = field.querySelector(':scope > [slot="controls"]');
290
+ const after =
291
+ field.querySelector(':scope > [slot="suffix"]') ||
292
+ field.querySelector(':scope > [slot="text"]');
293
+ if (after) {
294
+ let anchor = after;
295
+ for (const node of trailingNodes) {
296
+ anchor.after(node);
297
+ anchor = node;
298
+ }
299
+ } else if (controls) {
300
+ let anchor = controls.previousElementSibling || controls;
301
+ for (const node of trailingNodes) {
302
+ anchor.after(node);
303
+ anchor = node;
304
+ }
305
+ } else {
306
+ for (const node of trailingNodes) field.appendChild(node);
307
+ }
308
+ }
309
+ }
310
+
211
311
  #promoteAffixes() {
212
312
  if (!this.isConnected) return;
213
313
  for (const which of ['prefix', 'suffix']) {
@@ -227,13 +227,13 @@
227
227
  ],
228
228
  "slots": {
229
229
  "leading": {
230
- "description": "Leading icon slot, sized to --content-height. Collapses text inline padding when present."
230
+ "description": "Leading affordance slot, inside the field chrome, before the\nvalue. Sized to chrome height. Author `<button-ui slot=\"leading\"\nicon=\"...\" variant=\"ghost\" size=\"sm\">` (or any inline element)\nfor inline actions before the value — e.g. a link-icon button\nnext to a URL input. Wired §199 v0.5.7."
231
231
  },
232
232
  "text": {
233
233
  "description": "Contenteditable text surface for user input"
234
234
  },
235
235
  "trailing": {
236
- "description": "Trailing icon slot, sized to --content-height. Collapses text inline padding when present."
236
+ "description": "Trailing affordance slot, inside the field chrome, after the\nvalue (and after [slot=\"suffix\"] if present). Sized to chrome\nheight. Author `<button-ui slot=\"trailing\" icon=\"...\"\nvariant=\"ghost\" size=\"sm\" aria-label=\"...\">` for inline actions\nlike copy / clear / open-in-modal. For trailing text/icon\nlabels (e.g. \"Light\" in a theme picker), use the `suffix` prop\ninstead — affordance slots are for interactive buttons, not\ntext. Wired §199 v0.5.7."
237
237
  }
238
238
  },
239
239
  "states": [
@@ -268,6 +268,63 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
268
268
  margin-inline-start: auto;
269
269
  }
270
270
 
271
+ /* §199 (v0.5.7) — Leading + trailing affordance slots inside the field.
272
+ Consumer authors `<button-ui slot="leading|trailing">` as a direct
273
+ child of <input-ui>; class.js#installAffordances moves it into
274
+ [slot="field"] at the right insertion point on connected(). CSS
275
+ normalizes the chrome here.
276
+
277
+ The yaml declared these slots since v1 ("sized to --content-height,
278
+ collapses text inline padding when present") but no shell rule ever
279
+ wired them — the canonical schema-vs-impl gap that motivated audit
280
+ slot 19 (§192 v0.5.6). §199 closes the gap for input-ui specifically. */
281
+
282
+ [slot="field"] > [slot="leading"],
283
+ [slot="field"] > [slot="trailing"] {
284
+ flex-shrink: 0;
285
+ /* Sized to chrome height per yaml contract. The button-ui or icon-ui
286
+ child receives the sizing tokens; we just constrain the slot box
287
+ and align it to the field's baseline. */
288
+ align-self: stretch;
289
+ display: inline-flex;
290
+ align-items: center;
291
+ /* Inline padding moves from [slot="text"] (handled by the field's px)
292
+ to the slot wrapper, so the button-ui sits flush with the field
293
+ chrome edge instead of inheriting the field's px gap. Authors who
294
+ want a gap between value and trailing affordance use [slot="suffix"]
295
+ for the text — the auto-margin on suffix pushes everything after it
296
+ to the inline-end edge naturally. */
297
+ }
298
+
299
+ /* Default sizing for `<button-ui>` children of the affordance slots.
300
+ Token-driven so consumers can override via standard --button-* hooks.
301
+ Without these defaults, a vanilla `<button-ui slot="trailing" icon="...">`
302
+ would render at button-ui's default --button-height (40px md) and
303
+ blow out the input chrome. We bind to the field's content height
304
+ instead so the button visually matches the input's intrinsic size. */
305
+ [slot="field"] > [slot="leading"] button-ui,
306
+ [slot="field"] > [slot="trailing"] button-ui {
307
+ --button-height: calc(var(--input-height) - 4px);
308
+ --button-bg: transparent;
309
+ --button-border: transparent;
310
+ --button-fg: var(--input-affix-fg);
311
+ --button-px: var(--a-space-1);
312
+ }
313
+
314
+ [slot="field"] > [slot="leading"] button-ui:hover,
315
+ [slot="field"] > [slot="trailing"] button-ui:hover {
316
+ --button-bg: var(--a-ui-bg-hover);
317
+ --button-fg: var(--a-fg);
318
+ }
319
+
320
+ /* When a trailing affordance is present in number mode, reserve room
321
+ for the stepper column so it doesn't overlap. (controls is
322
+ absolutely positioned at inset-inline-end: 0; trailing sits in the
323
+ flex flow.) Same pattern as [slot="suffix"] in number mode. */
324
+ [data-number]:has(> [slot="trailing"]) > [slot="trailing"] {
325
+ margin-inline-end: var(--input-controls-width, calc(var(--input-height) * 0.7));
326
+ }
327
+
271
328
  /* Disabled */
272
329
  :scope[disabled] [slot="field"] {
273
330
  background: var(--input-bg-disabled);
@@ -58,4 +58,6 @@ export class UIInput extends UIFormElement {
58
58
  ): void;
59
59
  addEventListener(type: 'change', listener: (ev: InputChangeEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
60
60
  addEventListener(type: 'input', listener: (ev: InputInputEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
61
+ /** §207 (v0.5.7): Enter-key + native form-submit semantics; dispatched bubbling. */
62
+ addEventListener(type: 'submit', listener: (ev: Event) => unknown, options?: boolean | AddEventListenerOptions): void;
61
63
  }
@@ -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.
@@ -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,
@@ -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: []
@@ -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
  };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * toggle-scheme-ui §200 race-fix tests — v0.5.7 (FEEDBACK-10 §1).
3
+ *
4
+ * Verifies the post-connect attribute application is HONORED — not raced over
5
+ * by the synchronous #initState() call in connected(). Per the bug class:
6
+ *
7
+ * 1. template.js scan() strips placeholder attributes pre-insertion
8
+ * 2. replaceChildren() upgrades the element; connectedCallback fires
9
+ * 3. #initState() reads this.scheme (default 'auto')
10
+ * 4. template.js update() then setAttribute('scheme', 'dark')
11
+ * 5. attributeChangedCallback syncs to property — but #initState already ran
12
+ *
13
+ * Pre-§200, step 4 was a no-op for the scheme cascade. Post-§200,
14
+ * attributeChangedCallback re-runs #initState() (until user-touched).
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach } from 'vitest';
18
+ import '../../core/element.js';
19
+ import './toggle-scheme.js';
20
+
21
+ const tick = () => new Promise((r) => queueMicrotask(r));
22
+
23
+ function mount(html) {
24
+ const wrap = document.createElement('div');
25
+ wrap.innerHTML = html;
26
+ document.body.appendChild(wrap);
27
+ return wrap.firstElementChild;
28
+ }
29
+
30
+ describe('toggle-scheme-ui §200 race fix', () => {
31
+ beforeEach(() => {
32
+ document.body.innerHTML = '';
33
+ document.documentElement.style.removeProperty('color-scheme');
34
+ });
35
+
36
+ it('honors post-connect scheme attribute application (reactive consumer)', async () => {
37
+ // Simulate template.js's strip-then-restamp pattern: mount without scheme,
38
+ // then set the attribute immediately afterward (mimics post-connect apply).
39
+ const t = mount('<toggle-scheme-ui></toggle-scheme-ui>');
40
+ await tick();
41
+ t.setAttribute('scheme', 'dark');
42
+ await tick();
43
+ expect(t.activeScheme).toBe('dark');
44
+ expect(document.documentElement.style.colorScheme).toBe('dark');
45
+ });
46
+
47
+ it('honors the initial scheme attribute (no race scenario)', async () => {
48
+ const t = mount('<toggle-scheme-ui scheme="dark"></toggle-scheme-ui>');
49
+ await tick();
50
+ expect(t.activeScheme).toBe('dark');
51
+ });
52
+
53
+ it('user button press locks #userTouched — subsequent attr changes are ignored', async () => {
54
+ const t = mount('<toggle-scheme-ui scheme="dark"></toggle-scheme-ui>');
55
+ await tick();
56
+ expect(t.activeScheme).toBe('dark');
57
+
58
+ // User press: flips to light. #userTouched flips true.
59
+ const btn = t.querySelector(':scope > button-ui');
60
+ btn.dispatchEvent(new CustomEvent('press', { bubbles: true }));
61
+ await tick();
62
+ expect(t.activeScheme).toBe('light');
63
+
64
+ // Consumer re-renders with scheme="dark" — should NOT clobber user choice.
65
+ t.setAttribute('scheme', 'dark');
66
+ await tick();
67
+ expect(t.activeScheme).toBe('light'); // user choice survives
68
+ });
69
+
70
+ it('programmatic setScheme also locks #userTouched', async () => {
71
+ const t = mount('<toggle-scheme-ui scheme="auto"></toggle-scheme-ui>');
72
+ await tick();
73
+
74
+ t.setScheme('dark');
75
+ await tick();
76
+ expect(t.activeScheme).toBe('dark');
77
+
78
+ // Consumer reactive write should NOT override.
79
+ t.setAttribute('scheme', 'light');
80
+ await tick();
81
+ expect(t.activeScheme).toBe('dark');
82
+ });
83
+
84
+ it('toggle() also locks #userTouched', async () => {
85
+ const t = mount('<toggle-scheme-ui scheme="auto"></toggle-scheme-ui>');
86
+ await tick();
87
+ const initial = t.activeScheme; // depends on prefers-color-scheme (likely light in test env)
88
+
89
+ t.toggle();
90
+ await tick();
91
+ expect(t.activeScheme).not.toBe(initial);
92
+
93
+ const afterToggle = t.activeScheme;
94
+ t.setAttribute('scheme', initial === 'dark' ? 'dark' : 'light');
95
+ await tick();
96
+ expect(t.activeScheme).toBe(afterToggle); // user choice survives
97
+ });
98
+
99
+ it('removing the attribute (back to auto) is honored pre-touch', async () => {
100
+ const t = mount('<toggle-scheme-ui scheme="dark"></toggle-scheme-ui>');
101
+ await tick();
102
+ expect(t.activeScheme).toBe('dark');
103
+
104
+ t.removeAttribute('scheme');
105
+ await tick();
106
+ // After removal, attr is null; #initState falls to auto → resolves
107
+ // prefers-color-scheme. Test env should resolve to light by default.
108
+ expect(['light', 'dark']).toContain(t.activeScheme);
109
+ });
110
+ });
@@ -17,6 +17,12 @@ export type UploadChangeEvent = CustomEvent<UploadChangeEventDetail>;
17
17
  export class UIUpload extends UIFormElement {
18
18
  /** Files currently selected. */
19
19
  readonly files: FileList | File[];
20
+ /** §207 (v0.5.7): label rendered above the dropzone. */
21
+ label: string;
22
+ /** §207 (v0.5.7): MIME-type accept filter (e.g. `"image/*"` or `".pdf,.txt"`). */
23
+ accept: string;
24
+ /** §207 (v0.5.7): allow multi-file selection. */
25
+ multiple: boolean;
20
26
 
21
27
  addEventListener<K extends keyof HTMLElementEventMap>(
22
28
  type: K,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.5.6",
4
- "description": "AdiaUI web components \u2014 vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
3
+ "version": "0.5.7",
4
+ "description": "AdiaUI web components 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",
7
7
  "exports": {
@@ -28,6 +28,8 @@
28
28
  "default": "./components/*/class.js"
29
29
  },
30
30
  "./components/*.css": "./components/*/*.css",
31
+ "./components/*/*.css": "./components/*/*.css",
32
+ "./components/*/css/*.css": "./components/*/css/*.css",
31
33
  "./styles/*": "./styles/*",
32
34
  "./traits": "./traits/index.js",
33
35
  "./traits/*": "./traits/*.js",
@@ -13,6 +13,7 @@
13
13
  /* ── Components ── */
14
14
  @import "../components/icon/icon.css";
15
15
  @import "../components/button/button.css";
16
+ @import "../components/link/link.css";
16
17
  @import "../components/input/input.css";
17
18
  @import "../components/textarea/textarea.css";
18
19
  @import "../components/check/check.css";
@@ -84,6 +85,7 @@
84
85
  @import "../components/block/block.css";
85
86
  @import "../components/text/text.css";
86
87
  @import "../components/toggle-group/toggle-group.css";
88
+ @import "../components/toggle-scheme/toggle-scheme.css";
87
89
  @import "../components/demo-toggle/demo-toggle.css";
88
90
  @import "../components/richtext/richtext.css";
89
91
  @import "../components/stream/stream.css";