@adia-ai/web-components 0.7.23 → 0.7.25

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 (46) hide show
  1. package/CHANGELOG.md +56 -25
  2. package/README.md +2 -2
  3. package/USAGE.md +60 -15
  4. package/components/accordion/accordion-item.a2ui.json +30 -1
  5. package/components/accordion/accordion-item.yaml +33 -0
  6. package/components/accordion/accordion.a2ui.json +9 -0
  7. package/components/accordion/accordion.class.js +53 -3
  8. package/components/accordion/accordion.css +61 -3
  9. package/components/accordion/accordion.d.ts +12 -0
  10. package/components/accordion/accordion.yaml +17 -0
  11. package/components/code/code.class.js +1 -1
  12. package/components/display-field/display-field.a2ui.json +185 -0
  13. package/components/display-field/display-field.css +91 -0
  14. package/components/display-field/display-field.d.ts +26 -0
  15. package/components/display-field/display-field.examples.md +44 -0
  16. package/components/display-field/display-field.js +141 -0
  17. package/components/display-field/display-field.test.js +115 -0
  18. package/components/display-field/display-field.yaml +191 -0
  19. package/components/drawer/drawer.class.js +2 -2
  20. package/components/feed/feed.a2ui.json +1 -1
  21. package/components/feed/feed.class.js +1 -1
  22. package/components/feed/feed.d.ts +1 -1
  23. package/components/feed/feed.yaml +2 -2
  24. package/components/field/field.class.js +1 -1
  25. package/components/icon/icon.class.js +34 -1
  26. package/components/index.js +1 -0
  27. package/components/loading-overlay/loading-overlay.class.js +1 -1
  28. package/components/loading-overlay/loading-overlay.yaml +1 -1
  29. package/components/modal/modal.class.js +1 -1
  30. package/components/nav/nav.class.js +1 -1
  31. package/components/nav/nav.examples.md +4 -4
  32. package/components/popover/popover.class.js +1 -1
  33. package/components/spinner/spinner.class.js +1 -1
  34. package/components/spinner/spinner.yaml +1 -1
  35. package/components/table/table.class.js +42 -11
  36. package/components/table/table.test.js +53 -0
  37. package/components/toast/toast.class.js +1 -1
  38. package/core/icons.js +14 -0
  39. package/custom-elements.json +5723 -2865
  40. package/dist/theme-provider.min.js +1 -1
  41. package/dist/web-components.min.css +1 -1
  42. package/dist/web-components.min.js +87 -79
  43. package/dist/web-components.sheet.js +1 -1
  44. package/package.json +3 -2
  45. package/styles/components.css +1 -0
  46. package/traits/CATEGORIES.md +1 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * <display-field-ui label="Card on file" value="•••• 4242" variant="masked" icon="lock" hint="Expires 12/26"></display-field-ui>
3
+ *
4
+ * Display-only labeled value — a field-shaped read-out for a static or
5
+ * masked value, or a labeled slot that hosts a third-party element (a
6
+ * Stripe Element iframe) under a proper caption.
7
+ *
8
+ * Unlike <field-ui> it wraps NO interactive control: no focus, no
9
+ * contenteditable, no form participation, no [for]/id-minting. The label
10
+ * names the whole field for assistive tech via role="group" +
11
+ * aria-labelledby, so a slotted foreign element is announced with its
12
+ * caption.
13
+ *
14
+ * Value comes from EITHER the `value` attribute OR unnamed default-slot
15
+ * children (the foreign-element path) — slot content wins when both are
16
+ * present.
17
+ */
18
+
19
+ import { UIElement } from '../../core/element.js';
20
+ import { defineIfFree } from '../../core/register.js';
21
+
22
+ // Monotonic id source for label/hint association (aria-labelledby /
23
+ // aria-describedby need stable ids; multiple instances must not collide).
24
+ let uid = 0;
25
+
26
+ class UIDisplayField extends UIElement {
27
+ static properties = {
28
+ label: { type: String, default: '', reflect: true },
29
+ value: { type: String, default: '', reflect: true },
30
+ variant: { type: String, default: 'default', reflect: true },
31
+ hint: { type: String, default: '', reflect: true },
32
+ icon: { type: String, default: '', reflect: true },
33
+ };
34
+
35
+ static template = () => null;
36
+
37
+ #labelEl = null;
38
+ #valueEl = null;
39
+ #hintEl = null;
40
+ #iconEl = null;
41
+
42
+ connected() {
43
+ // A non-interactive labeled group — the label is the accessible name,
44
+ // which holds even when the value region is a foreign iframe.
45
+ this.setAttribute('role', 'group');
46
+
47
+ // The label element is the group's accessible-name source; create it
48
+ // once and mint a stable id for aria-labelledby. Prepend so it reads
49
+ // first for assistive tech (visual placement is grid-area driven).
50
+ this.#labelEl = this.querySelector(':scope > [slot="label"]');
51
+ if (!this.#labelEl) {
52
+ this.#labelEl = document.createElement('span');
53
+ this.#labelEl.setAttribute('slot', 'label');
54
+ this.insertBefore(this.#labelEl, this.firstChild);
55
+ }
56
+ if (!this.#labelEl.id) this.#labelEl.id = `display-field-label-${++uid}`;
57
+
58
+ // Re-bind any optional slot children that already exist in the light DOM.
59
+ // On a DOM move (disconnect → reconnect — e.g. UIElement.reconcile's
60
+ // insertBefore during a keyed list reorder) disconnected() nulled these
61
+ // fields but left the nodes in place; without re-binding, render() would
62
+ // see the null field and stamp a DUPLICATE. Query-or-leave-null mirrors
63
+ // the label handling above.
64
+ this.#valueEl = this.querySelector(':scope > [slot="value"]');
65
+ this.#hintEl = this.querySelector(':scope > [slot="hint"]');
66
+ this.#iconEl = this.querySelector(':scope > icon-ui[slot="icon"]');
67
+ }
68
+
69
+ render() {
70
+ if (!this.#labelEl) return;
71
+
72
+ // ── Label — the group's accessible name. ──
73
+ if (this.label) {
74
+ this.#labelEl.textContent = this.label;
75
+ this.#labelEl.hidden = false;
76
+ this.setAttribute('aria-labelledby', this.#labelEl.id);
77
+ } else {
78
+ this.#labelEl.hidden = true;
79
+ this.removeAttribute('aria-labelledby');
80
+ }
81
+
82
+ // ── Value — attribute path. Suppressed when the consumer slotted
83
+ // foreign content (a Stripe mount, rich markup): that content owns
84
+ // the value region. ──
85
+ const foreign = this.querySelector(':scope > :not([slot])');
86
+ if (foreign) {
87
+ if (this.#valueEl) { this.#valueEl.remove(); this.#valueEl = null; }
88
+ } else {
89
+ if (!this.#valueEl) {
90
+ this.#valueEl = document.createElement('span');
91
+ this.#valueEl.setAttribute('slot', 'value');
92
+ this.appendChild(this.#valueEl);
93
+ }
94
+ this.#valueEl.textContent = this.value;
95
+ }
96
+
97
+ // ── Hint — optional caption; drives aria-describedby. Removed when
98
+ // absent so no empty grid row leaks a gap. ──
99
+ if (this.hint) {
100
+ if (!this.#hintEl) {
101
+ this.#hintEl = document.createElement('span');
102
+ this.#hintEl.setAttribute('slot', 'hint');
103
+ this.#hintEl.id = `display-field-hint-${++uid}`;
104
+ this.appendChild(this.#hintEl);
105
+ }
106
+ this.#hintEl.textContent = this.hint;
107
+ this.setAttribute('aria-describedby', this.#hintEl.id);
108
+ } else if (this.#hintEl) {
109
+ this.#hintEl.remove();
110
+ this.#hintEl = null;
111
+ this.removeAttribute('aria-describedby');
112
+ }
113
+
114
+ // ── Icon — optional leading glyph. Removed when absent so the icon
115
+ // column collapses (the CSS gates the two-column layout on a
116
+ // present [slot="icon"] child). ──
117
+ if (this.icon) {
118
+ if (!this.#iconEl) {
119
+ this.#iconEl = document.createElement('icon-ui');
120
+ this.#iconEl.setAttribute('slot', 'icon');
121
+ this.#iconEl.setAttribute('aria-hidden', 'true');
122
+ this.appendChild(this.#iconEl);
123
+ }
124
+ this.#iconEl.setAttribute('name', this.icon);
125
+ } else if (this.#iconEl) {
126
+ this.#iconEl.remove();
127
+ this.#iconEl = null;
128
+ }
129
+ }
130
+
131
+ disconnected() {
132
+ this.#labelEl = null;
133
+ this.#valueEl = null;
134
+ this.#hintEl = null;
135
+ this.#iconEl = null;
136
+ }
137
+ }
138
+
139
+ defineIfFree('display-field-ui', UIDisplayField);
140
+
141
+ export { UIDisplayField };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * display-field-ui — display-only labeled value (upstream finding F5).
3
+ *
4
+ * Covers the contract that makes it NOT a control: group-label a11y, the
5
+ * dual value path (attr vs foreign slot), and the optional icon/hint that
6
+ * are removed (not just hidden) when their attribute clears.
7
+ */
8
+ import { describe, it, expect, beforeEach } from 'vitest';
9
+ import '../../core/element.js';
10
+ import './display-field.js';
11
+ import '../icon/icon.js';
12
+
13
+ const raf = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
14
+
15
+ function mount(html) {
16
+ const wrap = document.createElement('div');
17
+ wrap.innerHTML = html;
18
+ document.body.appendChild(wrap);
19
+ return wrap.firstElementChild;
20
+ }
21
+
22
+ describe('display-field-ui', () => {
23
+ beforeEach(() => { document.body.innerHTML = ''; });
24
+
25
+ it('renders as a labeled group: role=group + aria-labelledby → the label element', async () => {
26
+ const el = mount('<display-field-ui label="Account ID" value="acct_1Q9xK2"></display-field-ui>');
27
+ await raf();
28
+ expect(el.getAttribute('role')).toBe('group');
29
+ const labelEl = el.querySelector(':scope > [slot="label"]');
30
+ expect(labelEl).not.toBeNull();
31
+ expect(labelEl.textContent).toBe('Account ID');
32
+ expect(labelEl.id).toBeTruthy();
33
+ expect(el.getAttribute('aria-labelledby')).toBe(labelEl.id);
34
+ });
35
+
36
+ it('carries no input/focus/form semantics (no contenteditable, not focusable, no tabindex)', async () => {
37
+ const el = mount('<display-field-ui label="Card" value="•••• 4242"></display-field-ui>');
38
+ await raf();
39
+ expect(el.getAttribute('contenteditable')).toBeNull();
40
+ expect(el.getAttribute('tabindex')).toBeNull();
41
+ const valueEl = el.querySelector(':scope > [slot="value"]');
42
+ expect(valueEl.getAttribute('contenteditable')).toBeNull();
43
+ expect(valueEl.textContent).toBe('•••• 4242');
44
+ });
45
+
46
+ it('variant="masked" reflects and the value text is preserved', async () => {
47
+ const el = mount('<display-field-ui label="Card" value="•••• 4242" variant="masked"></display-field-ui>');
48
+ await raf();
49
+ expect(el.getAttribute('variant')).toBe('masked');
50
+ expect(el.querySelector(':scope > [slot="value"]').textContent).toBe('•••• 4242');
51
+ });
52
+
53
+ it('a foreign default-slot child owns the value region — the attr value element is suppressed', async () => {
54
+ const el = mount('<display-field-ui label="Card number"><div id="mount">[stripe]</div></display-field-ui>');
55
+ await raf();
56
+ // No auto [slot="value"] element — the foreign child is the value.
57
+ expect(el.querySelector(':scope > [slot="value"]')).toBeNull();
58
+ const foreign = el.querySelector(':scope > #mount');
59
+ expect(foreign).not.toBeNull();
60
+ expect(foreign.getAttribute('slot')).toBeNull(); // genuinely unnamed
61
+ });
62
+
63
+ it('icon attribute creates a leading icon-ui; clearing it removes the element (no phantom column)', async () => {
64
+ const el = mount('<display-field-ui label="Card" value="•••• 4242" icon="lock"></display-field-ui>');
65
+ await raf();
66
+ const iconEl = el.querySelector(':scope > icon-ui[slot="icon"]');
67
+ expect(iconEl).not.toBeNull();
68
+ expect(iconEl.getAttribute('name')).toBe('lock');
69
+
70
+ el.icon = '';
71
+ await raf();
72
+ expect(el.querySelector(':scope > [slot="icon"]')).toBeNull();
73
+ });
74
+
75
+ it('hint attribute creates a hint element + aria-describedby; clearing it removes both', async () => {
76
+ const el = mount('<display-field-ui label="Card" value="•••• 4242" hint="Expires 12/26"></display-field-ui>');
77
+ await raf();
78
+ const hintEl = el.querySelector(':scope > [slot="hint"]');
79
+ expect(hintEl).not.toBeNull();
80
+ expect(hintEl.textContent).toBe('Expires 12/26');
81
+ expect(el.getAttribute('aria-describedby')).toBe(hintEl.id);
82
+
83
+ el.hint = '';
84
+ await raf();
85
+ expect(el.querySelector(':scope > [slot="hint"]')).toBeNull();
86
+ expect(el.getAttribute('aria-describedby')).toBeNull();
87
+ });
88
+
89
+ it('surviving a DOM move (disconnect→reconnect) does NOT duplicate slot children', async () => {
90
+ const host1 = mount('<div></div>');
91
+ const el = document.createElement('display-field-ui');
92
+ el.setAttribute('label', 'Card');
93
+ el.setAttribute('value', '•••• 4242');
94
+ el.setAttribute('hint', 'Expires 12/26');
95
+ el.setAttribute('icon', 'lock');
96
+ host1.appendChild(el);
97
+ await raf();
98
+ expect(el.querySelectorAll(':scope > [slot="value"]').length).toBe(1);
99
+ expect(el.querySelectorAll(':scope > [slot="hint"]').length).toBe(1);
100
+ expect(el.querySelectorAll(':scope > icon-ui[slot="icon"]').length).toBe(1);
101
+
102
+ // Move to a new parent — fires disconnectedCallback then connectedCallback,
103
+ // exactly what UIElement.reconcile's insertBefore does on a keyed reorder.
104
+ const host2 = mount('<div></div>');
105
+ host2.appendChild(el);
106
+ await raf();
107
+
108
+ // No duplicates after the move — connected() re-binds the existing nodes.
109
+ expect(el.querySelectorAll(':scope > [slot="label"]').length).toBe(1);
110
+ expect(el.querySelectorAll(':scope > [slot="value"]').length).toBe(1);
111
+ expect(el.querySelectorAll(':scope > [slot="hint"]').length).toBe(1);
112
+ expect(el.querySelectorAll(':scope > icon-ui[slot="icon"]').length).toBe(1);
113
+ expect(el.querySelector(':scope > [slot="value"]').textContent).toBe('•••• 4242');
114
+ });
115
+ });
@@ -0,0 +1,191 @@
1
+ # Edit this file; run `npm run build:components` to regenerate a2ui.json.
2
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
3
+ name: UIDisplayField
4
+ tag: display-field-ui
5
+ status: stable
6
+ composes:
7
+ - icon-ui
8
+ component: DisplayField
9
+ category: form
10
+ version: 1
11
+ description: >-
12
+ Display-only labeled value — a field-shaped read-out for a static or
13
+ masked value (e.g. a card on file "•••• 4242"), or a labeled slot that
14
+ hosts a third-party element (a Stripe Element iframe) under a proper
15
+ caption. Unlike field-ui it wraps NO interactive control: no focus, no
16
+ contenteditable, no form participation, no [for]/id-minting. The label
17
+ names the whole field for assistive tech via role="group" +
18
+ aria-labelledby, so a slotted foreign element (iframe) is announced
19
+ with its caption. Reach for field-ui + input-ui when the value is
20
+ EDITABLE; stat-ui for a prominent metric/KPI; description-list-ui for
21
+ many read-only key/value pairs at once.
22
+ props:
23
+ label:
24
+ description: The field caption. Names the whole field for assistive tech.
25
+ required: true
26
+ type: string
27
+ default: ""
28
+ reflect: true
29
+ value:
30
+ description: >-
31
+ The static value text to display (e.g. "•••• 4242"). Ignored when
32
+ the default slot already carries content — slot a foreign element
33
+ OR set value=, not both.
34
+ type: string
35
+ default: ""
36
+ reflect: true
37
+ variant:
38
+ description: >-
39
+ Cosmetic value treatment. `default` renders the value in the UI
40
+ font; `masked` switches to the code/mono family with tabular
41
+ numerals and light letter-spacing so masked strings ("•••• 4242")
42
+ align cleanly. Tokens only — no layout change.
43
+ type: string
44
+ default: default
45
+ enum: [default, masked]
46
+ reflect: true
47
+ hint:
48
+ description: >-
49
+ Optional caption rendered below the value in subtle style (e.g.
50
+ "Expires 12/26"). Wired into the field's aria-describedby.
51
+ type: string
52
+ default: ""
53
+ reflect: true
54
+ icon:
55
+ description: >-
56
+ Optional leading icon name shown beside the value (e.g. a lock or
57
+ card-brand glyph). Lazily creates a single icon-ui child.
58
+ type: string
59
+ default: ""
60
+ reflect: true
61
+ events: {}
62
+ slots:
63
+ default:
64
+ description: >-
65
+ The value content. Slot a third-party element here — a Stripe
66
+ Element iframe mount, or rich markup — and it takes the value
67
+ region under the label, replacing the [value] attribute. The
68
+ host's role="group" + aria-labelledby names it for assistive tech.
69
+ label:
70
+ description: >-
71
+ The caption element. Auto-stamped from the [label] attribute; slot
72
+ your own [slot="label"] child to override with richer markup. It is
73
+ the group's accessible name (the aria-labelledby target).
74
+ value:
75
+ description: >-
76
+ The static value element. Auto-stamped from the [value] attribute;
77
+ suppressed when the default slot carries foreign content.
78
+ hint:
79
+ description: >-
80
+ The hint caption below the value. Auto-stamped from the [hint]
81
+ attribute; wired into aria-describedby.
82
+ icon:
83
+ description: >-
84
+ The leading icon (a single icon-ui). Auto-stamped from the [icon]
85
+ attribute.
86
+ states:
87
+ - name: idle
88
+ description: Default, non-interactive read-out.
89
+ traits: []
90
+ tokens:
91
+ --display-field-gap:
92
+ description: Vertical gap between the label, value, and hint rows.
93
+ --display-field-icon-gap:
94
+ description: Horizontal gap between the leading icon and the value.
95
+ --display-field-label-color:
96
+ description: Label foreground color.
97
+ --display-field-label-size:
98
+ description: Label font size.
99
+ --display-field-label-weight:
100
+ description: Label font weight.
101
+ --display-field-value-color:
102
+ description: Value foreground color.
103
+ --display-field-value-size:
104
+ description: Value font size.
105
+ --display-field-value-weight:
106
+ description: Value font weight.
107
+ --display-field-hint-color:
108
+ description: Hint foreground color.
109
+ --display-field-hint-size:
110
+ description: Hint font size.
111
+ --display-field-masked-font:
112
+ description: Font family applied to a masked value.
113
+ --display-field-masked-tracking:
114
+ description: Letter-spacing applied to a masked value.
115
+ a2ui:
116
+ rules:
117
+ - rule: "Use for a display-ONLY labeled value — a masked card number, a 'card on file' read-out, an account id — anywhere a value reads as a field but must not be editable."
118
+ reason: "field-ui wraps an editable control; display-field-ui carries no input/focus/form semantics."
119
+ - rule: "To host a third-party element (a Stripe Element iframe) under a label, slot it as the default child instead of setting value=. The label names it via role=group + aria-labelledby."
120
+ reason: "field-ui auto-mints id/[for] on a real control; a foreign iframe is not labelable, so group-labelling is the correct a11y path."
121
+ - rule: "For an editable field use field-ui + input-ui; for a prominent KPI use stat-ui; for many read-only pairs at once use description-list-ui."
122
+ reason: "Distinct roles — don't reach for display-field-ui when the value is editable or when it's a metric."
123
+ anti_patterns:
124
+ - description: >-
125
+ Using input-ui[readonly] to render a static masked value. A
126
+ readonly input still carries contenteditable/focus semantics and
127
+ tab-stops; a display read-out should not be a control.
128
+ wrong: |
129
+ <input-ui readonly value="•••• 4242"></input-ui>
130
+ right: |
131
+ <display-field-ui label="Card on file" value="•••• 4242" variant="masked"></display-field-ui>
132
+ rule: A non-editable masked value is display-field-ui, not a readonly input.
133
+ examples:
134
+ - name: card-on-file
135
+ description: A masked "card on file" read-out with a leading lock icon and an expiry hint.
136
+ a2ui: >-
137
+ [
138
+ {
139
+ "id": "root",
140
+ "component": "DisplayField",
141
+ "label": "Card on file",
142
+ "value": "•••• •••• •••• 4242",
143
+ "variant": "masked",
144
+ "icon": "lock",
145
+ "hint": "Expires 12/26"
146
+ }
147
+ ]
148
+ - name: foreign-element-slot
149
+ description: A labeled slot hosting a third-party element (e.g. a Stripe Element mount).
150
+ a2ui: >-
151
+ [
152
+ {
153
+ "id": "root",
154
+ "component": "DisplayField",
155
+ "label": "Card number",
156
+ "children": ["mount"]
157
+ },
158
+ { "id": "mount", "component": "Text", "text": "[Stripe Element mounts here]" }
159
+ ]
160
+ keywords:
161
+ - display field
162
+ - readonly
163
+ - read-only
164
+ - masked
165
+ - mask
166
+ - static value
167
+ - card on file
168
+ - last four
169
+ - value
170
+ - readout
171
+ - stripe element
172
+ - iframe slot
173
+ - non-editable
174
+ synonyms:
175
+ masked:
176
+ - display field
177
+ - field
178
+ - value
179
+ readout:
180
+ - display field
181
+ - stat
182
+ - field
183
+ value:
184
+ - display field
185
+ - field
186
+ - stat
187
+ related:
188
+ - field
189
+ - input
190
+ - stat
191
+ - description-list
@@ -88,7 +88,7 @@ export class UIDrawer extends UIElement {
88
88
  // after the property change, which Safari treats as outside the user-gesture
89
89
  // window and silently no-ops the showModal. Wrap the auto-installed `open`
90
90
  // setter so dialog state syncs in the same synchronous frame as the
91
- // assignment. See docs/BROWSER-COMPAT.md §3a (Flavor C).
91
+ // assignment. See .claude/docs/BROWSER-COMPAT.md §3a (Flavor C).
92
92
  const desc = Object.getOwnPropertyDescriptor(this, 'open');
93
93
  if (desc?.set) {
94
94
  const origSet = desc.set;
@@ -337,7 +337,7 @@ export class UIDrawer extends UIElement {
337
337
  // requestAnimationFrame when a top-layer dialog is open, sometimes
338
338
  // delaying [data-open] (and the slide-in transition) by tens of
339
339
  // seconds. Forcing a reflow keeps the animation start in the same
340
- // synchronous frame. See docs/BROWSER-COMPAT.md §3a (Flavor C).
340
+ // synchronous frame. See .claude/docs/BROWSER-COMPAT.md §3a (Flavor C).
341
341
  void dialog.offsetHeight;
342
342
  dialog.setAttribute('data-open', '');
343
343
  // §FB-27 (P2): emit `opened` after the slide-in transition completes
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://adiaui.dev/a2ui/v0_9/components/Feed.json",
4
4
  "title": "Feed",
5
- "description": "Shared top-layer feed channel. Per docs/specs/feed-channel.md (SPEC-FEED-CHANNEL-001). Per-position singletons mounted lazily into document.body via Popover API; consumers post via the static API (`UIFeed.post()`) or the global 'feed' CustomEvent.",
5
+ "description": "Shared top-layer feed channel. Per .claude/docs/specs/feed-channel.md (SPEC-FEED-CHANNEL-001). Per-position singletons mounted lazily into document.body via Popover API; consumers post via the static API (`UIFeed.post()`) or the global 'feed' CustomEvent.",
6
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -14,7 +14,7 @@
14
14
  /**
15
15
  * <feed-ui> + <feed-item-ui> — Shared top-layer feed channel.
16
16
  *
17
- * Per docs/specs/feed-channel.md (SPEC-FEED-CHANNEL-001).
17
+ * Per .claude/docs/specs/feed-channel.md (SPEC-FEED-CHANNEL-001).
18
18
  *
19
19
  * Phase 1 (skeleton) ships here:
20
20
  * - Per-position singletons (`<feed-ui position="bottom-right">`)
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `<feed-ui>` — Shared top-layer feed channel. Per docs/specs/feed-channel.md
2
+ * `<feed-ui>` — Shared top-layer feed channel. Per .claude/docs/specs/feed-channel.md
3
3
  * (SPEC-FEED-CHANNEL-001). Per-position singletons mounted lazily into
4
4
  * document.body via Popover API; consumers post via the static API
5
5
  * (`UIFeed.post()`) or the global 'feed' CustomEvent.
@@ -8,7 +8,7 @@ component: Feed
8
8
  category: container
9
9
  version: 1
10
10
  description: >-
11
- Shared top-layer feed channel. Per docs/specs/feed-channel.md
11
+ Shared top-layer feed channel. Per .claude/docs/specs/feed-channel.md
12
12
  (SPEC-FEED-CHANNEL-001). Per-position singletons mounted lazily into
13
13
  document.body via Popover API; consumers post via the static API
14
14
  (`UIFeed.post()`) or the global 'feed' CustomEvent.
@@ -60,7 +60,7 @@ related:
60
60
  a2ui:
61
61
  rules:
62
62
  - rule: 'Top-layer notification feed channel — singleton per position (top-right, bottom-center, etc.) mounted lazily into document.body.'
63
- reason: 'Per docs/specs/feed-channel.md.'
63
+ reason: 'Per .claude/docs/specs/feed-channel.md.'
64
64
  - rule: 'Hosts <feed-item-ui> children programmatically via static API (UIFeed.post(...)). Do not place feed-items declaratively.'
65
65
  reason: 'Imperative API ownership.'
66
66
  - rule: 'For inline persistent alerts inside content regions use <alert-ui> instead; feed is ephemeral overlay.'
@@ -60,7 +60,7 @@ export class UIField extends UIElement {
60
60
  // is the single source of truth — validity is a property of the value, not
61
61
  // of the layout wrapper). field-ui mirrors the child's error string into
62
62
  // its own [data-field-error] slot so the validation message renders below
63
- // the control. See docs/specs/component-token-contract.md "Toggle state
63
+ // the control. See .claude/docs/specs/component-token-contract.md "Toggle state
64
64
  // naming" / open decisions resolution log.
65
65
  get error() {
66
66
  const ctrl = this.#findControl();
@@ -12,7 +12,35 @@
12
12
  */
13
13
 
14
14
  import { UIElement } from '../../core/element.js';
15
- import { getIcon, whenIconRegistryReady } from '../../core/icons.js';
15
+ import { getIcon, whenIconRegistryReady, iconRegistryUnwired } from '../../core/icons.js';
16
+
17
+ // Upstream finding F1: the zero-config Phosphor glob path can leave the icon
18
+ // registry completely unwired under Vite 8 / rolldown (the `import.meta.glob`
19
+ // macro doesn't transform from inside a published dependency), so every
20
+ // `<icon-ui>` renders blank. The loader layer warns, but the per-icon miss is
21
+ // silent (`getIcon → ''`). Fail loud ONCE from the component too, with the
22
+ // actionable fix. Deferred a macrotask so an app that calls
23
+ // `installIconLoaders()` at entry (the correct path) is never false-flagged.
24
+ let _unwiredCheckScheduled = false;
25
+ function warnIfIconRegistryUnwired() {
26
+ if (_unwiredCheckScheduled) return;
27
+ _unwiredCheckScheduled = true;
28
+ setTimeout(() => {
29
+ if (!iconRegistryUnwired()) return; // loaders arrived after all — fine
30
+ // eslint-disable-next-line no-console
31
+ console.error(
32
+ `[icon-ui] No icons are registered and no icon loaders were installed — every <icon-ui> renders blank.\n` +
33
+ ` The zero-config import.meta.glob path does not work from inside a published dependency under Vite 8 / rolldown.\n` +
34
+ ` Fix — from your APP entry (where import.meta.glob is transformed):\n` +
35
+ ` npm i @phosphor-icons/core\n` +
36
+ ` import { installIconLoaders } from '@adia-ai/web-components/core/icons';\n` +
37
+ ` installIconLoaders({ regular: import.meta.glob(\n` +
38
+ ` '/node_modules/@phosphor-icons/core/assets/regular/*.svg',\n` +
39
+ ` { query: '?raw', import: 'default', eager: true }) });\n` +
40
+ ` See USAGE.md § Icons.`,
41
+ );
42
+ }, 0);
43
+ }
16
44
 
17
45
  export class UIIcon extends UIElement {
18
46
  static properties = {
@@ -62,6 +90,11 @@ export class UIIcon extends UIElement {
62
90
  if (this.label) this.setAttribute('aria-label', this.label);
63
91
  const svg = getIcon(this.name, this.weight || 'regular');
64
92
  if (svg && this.innerHTML !== svg) this.innerHTML = svg;
93
+ // A named icon that resolved to nothing AND a never-wired registry is the
94
+ // F1 symptom — schedule the one-shot fail-loud. A plain typo (registry
95
+ // populated, this one name absent) settles `iconRegistryUnwired()` false
96
+ // and is correctly NOT flagged here.
97
+ else if (this.name && !svg) warnIfIconRegistryUnwired();
65
98
 
66
99
  // Pixel / rem / em passthrough — `size="48"` → 48px,
67
100
  // `size="3.5rem"` → 3.5rem. Named-scale values (xs/sm/md/lg/xl/
@@ -71,6 +71,7 @@ export { UISwatch } from './swatch/swatch.js';
71
71
  export { UICol } from './col/col.js';
72
72
  export { UIField } from './field/field.js';
73
73
  export { UIFields } from './fields/fields.js';
74
+ export { UIDisplayField } from './display-field/display-field.js';
74
75
  export { UIRow } from './row/row.js';
75
76
  export { UIGrid } from './grid/grid.js';
76
77
  export { UIStack } from './stack/stack.js';
@@ -49,7 +49,7 @@
49
49
  * changes are driven by the reactive [active] setter wrapper, which
50
50
  * starts/cancels the timer + applies aria-busy when the timer fires.
51
51
  *
52
- * @see SPEC-034 (docs/specs/implementation-ready/SPEC-034-loading-overlay.md)
52
+ * @see SPEC-034 (.claude/docs/specs/implementation-ready/SPEC-034-loading-overlay.md)
53
53
  */
54
54
 
55
55
  import { UIElement } from '../../core/element.js';
@@ -1,4 +1,4 @@
1
- # Hand-authored per SPEC-034 (docs/specs/implementation-ready/SPEC-034-loading-overlay.md).
1
+ # Hand-authored per SPEC-034 (.claude/docs/specs/implementation-ready/SPEC-034-loading-overlay.md).
2
2
  # Edit this file; run `npm run build:components` to regenerate loading-overlay.a2ui.json.
3
3
  $schema: ../../../../scripts/schemas/component.yaml.schema.json
4
4
  name: UILoadingOverlay
@@ -212,7 +212,7 @@ export class UIModal extends UIElement {
212
212
  // Sync open state. Synchronous reflow instead of rAF — Safari throttles
213
213
  // requestAnimationFrame when a top-layer dialog is open, sometimes
214
214
  // delaying [data-open] (and the scale-in transition) by tens of seconds.
215
- // See docs/BROWSER-COMPAT.md §3a (Flavor C).
215
+ // See .claude/docs/BROWSER-COMPAT.md §3a (Flavor C).
216
216
  if (this.open && !dialog.open) {
217
217
  this.#closing = false;
218
218
  this.#previousFocus = document.activeElement;
@@ -134,7 +134,7 @@ export class UINav extends UIElement {
134
134
  // Safari macOS leaves :hover stuck on items the cursor passed through
135
135
  // when the DOM mutates during click+route navigation. Toggling pointer-
136
136
  // events forces re-evaluation of hover state on next paint.
137
- // See docs/BROWSER-COMPAT.md §3a.
137
+ // See .claude/docs/BROWSER-COMPAT.md §3a.
138
138
  #flushHoverState() {
139
139
  this.style.pointerEvents = 'none';
140
140
  requestAnimationFrame(() => {
@@ -18,10 +18,10 @@
18
18
 
19
19
  ```html
20
20
  <nav-ui variant="section" heading="Documentation" style="width:240px;">
21
- <nav-item-ui text="Overview" value="docs/overview" selected></nav-item-ui>
22
- <nav-item-ui text="Quickstart" value="docs/quickstart"></nav-item-ui>
23
- <nav-item-ui text="API reference" value="docs/api"></nav-item-ui>
24
- <nav-item-ui text="Examples" value="docs/examples"></nav-item-ui>
21
+ <nav-item-ui text="Overview" value=".claude/docs/overview" selected></nav-item-ui>
22
+ <nav-item-ui text="Quickstart" value=".claude/docs/quickstart"></nav-item-ui>
23
+ <nav-item-ui text="API reference" value=".claude/docs/api"></nav-item-ui>
24
+ <nav-item-ui text="Examples" value=".claude/docs/examples"></nav-item-ui>
25
25
  </nav-ui>
26
26
  ```
27
27
 
@@ -96,7 +96,7 @@ export class UIPopover extends UIElement {
96
96
  // try/catch covers test envs (happy-dom) without the API. Known iOS
97
97
  // Safari quirks at the supported floor (light-dismiss broken on
98
98
  // 17.0–18.2, virtual-keyboard-on-input persists past 18.3) are
99
- // documented in docs/BROWSER-COMPAT.md §3a.
99
+ // documented in .claude/docs/BROWSER-COMPAT.md §3a.
100
100
  try { content.showPopover(); } catch { /* popover API unavailable — anchor positioning still runs */ }
101
101
  }
102
102
 
@@ -36,7 +36,7 @@
36
36
  * sets the three ARIA attributes once; render() keeps aria-valuetext in
37
37
  * sync with the [label] prop.
38
38
  *
39
- * @see SPEC-001 (docs/specs/implementation-ready/SPEC-001-spinner-loader.md)
39
+ * @see SPEC-001 (.claude/docs/specs/implementation-ready/SPEC-001-spinner-loader.md)
40
40
  */
41
41
 
42
42
  import { UIElement } from '../../core/element.js';