@adia-ai/web-components 0.7.5 → 0.7.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 (69) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/components/action-list/action-list.a2ui.json +2 -2
  3. package/components/action-list/action-list.yaml +2 -2
  4. package/components/agent-artifact/agent-artifact.class.js +28 -12
  5. package/components/agent-artifact/agent-artifact.test.js +61 -0
  6. package/components/badge/badge.yaml +1 -0
  7. package/components/block/block.class.js +20 -4
  8. package/components/block/block.css +18 -13
  9. package/components/button/button.a2ui.json +1 -2
  10. package/components/button/button.css +17 -6
  11. package/components/button/button.yaml +1 -0
  12. package/components/card/card.yaml +1 -0
  13. package/components/chart/chart.a2ui.json +1 -2
  14. package/components/chart/chart.d.ts +1 -1
  15. package/components/chart/chart.yaml +1 -0
  16. package/components/check/check.a2ui.json +0 -3
  17. package/components/check/check.yaml +0 -2
  18. package/components/combobox/combobox.a2ui.json +1 -2
  19. package/components/combobox/combobox.yaml +1 -0
  20. package/components/command/command.a2ui.json +0 -3
  21. package/components/command/command.class.js +19 -1
  22. package/components/command/command.yaml +0 -2
  23. package/components/context-menu/context-menu.class.js +1 -0
  24. package/components/description-list/description-list.a2ui.json +1 -2
  25. package/components/description-list/description-list.d.ts +1 -1
  26. package/components/description-list/description-list.yaml +1 -0
  27. package/components/drawer/drawer.css +1 -1
  28. package/components/field/field.class.js +1 -0
  29. package/components/fields/fields.class.js +1 -0
  30. package/components/grid/grid.yaml +2 -0
  31. package/components/input/input.a2ui.json +2 -14
  32. package/components/input/input.yaml +2 -0
  33. package/components/integration-card/integration-card.class.js +1 -0
  34. package/components/list/list.class.js +1 -0
  35. package/components/list/list.yaml +1 -0
  36. package/components/menu/menu.class.js +1 -0
  37. package/components/menu/menu.css +14 -2
  38. package/components/pipeline-status/pipeline-status.yaml +1 -0
  39. package/components/radio/radio.a2ui.json +0 -3
  40. package/components/radio/radio.yaml +0 -2
  41. package/components/rating/rating.yaml +1 -0
  42. package/components/search/search.a2ui.json +1 -8
  43. package/components/search/search.yaml +1 -5
  44. package/components/select/select.a2ui.json +1 -2
  45. package/components/select/select.class.js +46 -1
  46. package/components/select/select.test.js +33 -0
  47. package/components/select/select.yaml +2 -0
  48. package/components/slider/slider.a2ui.json +0 -3
  49. package/components/slider/slider.yaml +0 -2
  50. package/components/swatch/swatch.class.js +1 -0
  51. package/components/table/table.a2ui.json +2 -4
  52. package/components/table/table.d.ts +2 -2
  53. package/components/table/table.yaml +2 -0
  54. package/components/tabs/tabs.yaml +1 -0
  55. package/components/tag/tag.yaml +1 -0
  56. package/components/toggle-group/toggle-group.yaml +1 -0
  57. package/components/toolbar/toolbar.class.js +1 -0
  58. package/components/tree/tree.a2ui.json +2 -2
  59. package/components/tree/tree.yaml +2 -2
  60. package/dist/host.min.css +1 -1
  61. package/dist/web-components.min.css +1 -1
  62. package/dist/web-components.min.js +29 -29
  63. package/index.css +11 -17
  64. package/package.json +1 -1
  65. package/styles/api/sizing.css +18 -6
  66. package/styles/foundation/space.css +33 -0
  67. package/styles/host.css +20 -22
  68. package/styles/index.css +14 -17
  69. package/styles/verse.css +24 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.7.7] — 2026-06-02
4
+
5
+ ### Fixed
6
+
7
+ - **`[verse]` register now scales `--a-toggle-size` (check / radio / switch) down with its type + controls.** Companion to the 0.7.6 `[verse]` icon/caret fix (`91460aae2`). `--a-toggle-size` (the check / radio / switch control size) is declared at `:root` (`styles/foundation/size.css` = `--a-size-sm − --a-space-1` = 20px @md) and shifted by the global `[size]` rules (`styles/api/sizing.css` = 16/24 at sm/lg), but was **absent from `styles/verse.css`** — so in a verse context check / radio / switch chrome stayed the `:root`-computed 20px against verse's smaller (18/20/24) controls and 11/12/13px type. Added `--a-toggle-size` to all three verse sites (base `[verse]` + `[verse][size="lg"]` + `[verse][size="sm"]`) on clean `--a-space` rungs, one tier down: verse toggle is now **12 / 16 / 20px** at sm/md/lg (was a flat 20px); the regular register is unchanged (16/20/24). The explicit `[size]` tier rules are **required** — verse's `context` layer shadows the lower-layer global `[size]` `--a-toggle-size` rules, the same reason `--a-size` and the icon/caret tokens need them. `--a-space` rungs (not literal px like icon/caret) because the toggle targets land exactly on rungs, keeping the values density-aware and consistent with verse's inset/gap. Verified by `scripts/qa/register-scale-probe.mjs` (extended with a toggle axis): verse 12/16/20, regular 16/20/24, all other registers/axes unchanged. Showcased on the `check` / `radio` / `switch` demos (a "Typography registers" section, mirroring the card / button / text / field / badge / stat pages). File: `styles/verse.css`.
8
+ - **`<agent-artifact-ui>` no longer strands `.map()`-rendered action buttons in the body.** `#build()` grabbed the author's `slot="primary"` / `slot="secondary"` buttons with a `:scope > [slot]` direct-child query, then `this.innerHTML = ''`. Buttons supplied via interpolation (`${actions.map(…)}`) arrive nested in the template engine's `display:contents` / `role="presentation"` wrapper spans, so the direct-child grab missed them and the wipe left them rendered in the artifact *body* instead of the header actions cluster. `#build()` now partitions children with a wrapper-piercing logical walk (the FB-92/96/98 pattern, mirroring `select-ui` `#logicalOptionChildren`), so interpolated AND static action buttons both reach the actions cluster. +2 regression tests. Surfaced by `audit-wrapper-trap` arm 2. File: `components/agent-artifact/agent-artifact.class.js`.
9
+
10
+ ### Changed
11
+
12
+ - **`<drawer-ui>` body inset is now register / size-aware (`--drawer-inset` → `var(--a-inset)`).** `--drawer-inset` hardcoded `var(--a-space-4)` (a literal 16px rung); it now reads `var(--a-inset)`, matching its sibling `card-ui` (`--card-inset: var(--a-inset)`). **Behavior-neutral at the default tier** (`--a-inset-md` == `--a-space-4` == 16px); the change is that the drawer body / header / footer inset (header & footer padding derive from `--drawer-inset`) now scales with `[size]` (sm 14 / lg 18), `[verse]` (12), and `[prose]` (40). drawer's `[padding]` / `[bleed]` are layout *modes* (canvas-bg / no-pad), not a value scale — unchanged. Part of the 0.7.7 inset-alignment audit, which left `page` / `canvas` / `table` filter / `richtext` `pre` as intentional fixed rungs (their literals aren't 16px, so a swap would change behavior). Files: `components/drawer/drawer.css`, `components/drawer/drawer.examples.html`.
13
+ - **CDN bundle rebuilt** — `dist/web-components.min.css` regenerated so the `[verse]` `--a-toggle-size` fix + the drawer inset reach `@adia-ai/web-components@0.7` CDN consumers.
14
+
15
+ ## [0.7.6] — 2026-06-02
16
+
17
+ ### Added
18
+
19
+ - **`[padding]` / `[margin]` named scale (`xs`–`xl`) — complete + parametric.** Previously the named padding/margin vocabulary was only `sm`/`md`/`lg` (literal `--a-space-4`/`6`/`8`). Now the full `xs`–`xl` set, each `calc(--a-space-N × --a-{padding,margin}-k)` — mirroring the `[gap]` scale's `value × --a-*-k` form. New `@property --a-padding-k` / `--a-margin-k` knobs (`<number>`, init 1, inherits — a root/provider-level control, subtree-inert like `--a-gap-k`/`--a-density`) retune all named padding/margin at once, per-dimension. `sm`/`md`/`lg` keep their prior values (16/24/32px @k=1) — **behavior-neutral**; `xs` (8px) + `xl` (40px) are new. Integer rungs (`[padding="4"]`) stay literal space-rungs (k-independent). Composes on `--a-density`. Verified: 8/16/24/32/40 @k=1; root `--a-padding-k:2` → md 48 / xl 80; `--a-margin-k` independent of padding. Files: `styles/foundation/space.css`, `styles/api/sizing.css`.
20
+
21
+ ### Changed
22
+
23
+ - **`styles/host.css` is now foundation-only (tokens + resets + page-frame); `styles/index.css` becomes the primitives barrel.** Previously `host.css` bundled the full library (tokens + all 120 component styles + resets, via `index.css`) plus the page-frame. Now: `host.css` = `tokens.css` + `resets.css` + `:where(html)` page-frame only; `styles/index.css` = `components.css` wrapped in `layer(components)` (the primitives barrel). Pages link both in order. The package-root `index.css` (`@adia-ai/web-components/css`) imports both and preserves the existing full-stack published entry — no change for consumers using the package entry. All 119 component dev shells and `site/index.html` updated. **Migration for `host.css`-only consumers:** add `<link rel="stylesheet" href="@adia-ai/web-components/styles/index.css" />` after the `host.css` link.
24
+ - **`<block-ui>` padding/margin unified with the global `[padding]`/`[margin]` scale — now parametric (behavior change).** `block-ui` carried its own literal padding/margin scale (`none`/`xs`/`sm`/`md`/`lg`/`xl` = `0`/`--a-space-1/2/4/6/10` = 0/4/8/16/24/40px, k-independent), duplicated across `block.css` (static `:scope[padding=…]` rules) and `block.class.js` `_SPACE` (the `@bp` responsive path), and diverging from the global `[padding]`/`[margin]` attribute API — `<block-ui padding="md">` rendered 16px while `[padding="md"]` is 24px (same vocabulary, different sizes). Both paths now reference the parametric `--a-{padding,margin}-{xs..xl}` tokens (`foundation/space.css`), so the two grammars AGREE (the same "one named scale" principle as the `[gap]` unification), `block-ui` tracks the `--a-padding-k`/`--a-margin-k` knobs, and the scale is no longer duplicated. **Behavior change** — `block-ui` spacing grows at `xs`/`sm`/`md`/`lg`: `xs` 4→8, `sm` 8→16, **`md` (the default) 16→24**, `lg` 24→32px; `xl` (40) + `none` (0) unchanged. Verified by computed-style probe (8/16/24/32/40 @k=1, default 24, `--a-padding-k:2` → md 48, `@bp` responsive + margins all correct, 0 console errors) + dev-shell visual QA. Files: `components/block/block.css`, `components/block/block.class.js`. **Migration:** a `<block-ui>` that relied on the prior tighter spacing should step the named value one rung down (`md`→`sm`, `lg`→`md`, …) or use the integer rungs (`padding="4"` = literal 16px, k-independent).
25
+ - **CDN bundles rebuilt** — `dist/host.min.css`, `dist/web-components.min.css`, and `dist/web-components.min.js` regenerated so the barrel split + `<block-ui>` + parametric `[padding]`/`[margin]` + `[verse]` re-scaling reach `@adia-ai/web-components@0.7` CDN consumers.
26
+
27
+ ### Fixed
28
+
29
+ - **`[verse]` register now scales icon + caret sizes down with its type + controls.** The compact `[verse]` register (`styles/verse.css`) shifted inset, gap, radius, control-size (`--a-size`), and every type role down — but left `--a-icon-size` (16px @md) and `--a-caret-size` (14px @md) at their regular-register values. So in a verse context a `<select-ui>` caret (an `<icon-ui>` reading `--a-icon-size`) stayed 16×16 against verse's 12px text in a 43px-tall control, and form-element chrome icons read full size — visually oversized. Added a one-tier-down icon/caret scale to `[verse]`, mirroring how it already re-implements `--a-size`: base `[verse]` = 14/12px (= regular-`sm`), `[verse][size="lg"]` = 16/14px (= regular-`md`), `[verse][size="sm"]` = 12/10px. The explicit `[size]` tier rules are **required** because the global `[size]` icon/caret rules live in the `utilities` layer, which verse's `context` layer shadows (same reason `--a-size` needs them). Covers both `<icon-ui>`-based carets (select / combobox, via `--a-icon-size`) and CSS-drawn carets (calendar-grid / calendar-picker / nav-group / pane, via `--a-caret-size`). Verified by computed-style + render-box probe: verse(md) caret 16→14px, verse-`sm` 14→12px, verse-`lg` 20→16px; regular register unchanged. File: `styles/verse.css`.
30
+ - **`<command-ui>` now parses `.map()`/`repeat()`-rendered `<option>`/`<optgroup>` palette items (5th sighting of the `display:contents` wrapper trap).** `#parseOptions()` walked `this.children` directly, so options rendered via `.map()` arrived nested in the template engine's `display:contents`/`role="presentation"` wrapper span and were missed — leaving an empty palette ("No results found.") when the consumer interpolated its option list. New `#logicalOptionChildren()` walks direct children and pierces wrapper spans, mirroring `select-ui#logicalOptionChildren` (FB-98). Eight other `for (const x of this.children)` loops across 8 components (context-menu, field, fields, integration-card, list, menu, swatch, toolbar) were audited and marked `// wrapper-trap-ok` — all read component-stamped or single-literal children that are never consumer-interpolated. `audit:wrapper-trap:strict` added to `check:dogfood-audits:strict`; the audit now reports 0 un-allowlisted loops. File: `components/command/command.class.js`.
31
+ - **`<select-ui>` now parses `.map()`/`repeat()`-rendered `<option>` children (FB-98).** `#parseOptions()` walked `this.children` and matched only `tagName === 'OPTION'`/`'OPTGROUP'`, so interpolated options — each nested in the template engine's `display:contents`/`role="presentation"` wrapper span (`core/template.js` Array branch) — were invisible, leaving the popover empty ("No options"); static-literal options worked (direct children, no wrapper). New `#logicalOptionChildren()` collects `<option>`/`<optgroup>` direct OR nested inside the wrapper spans (skipping component-stamped `[slot]` children), and a re-entrancy-guarded `MutationObserver` re-parses options that arrive after `connected()` (the template's `update()` pass) so the empty-state clears. The same `display:contents` wrapper trap fixed for `menu-ui#show()` (FB-92) and `tree-item-ui#stamp()` (FB-96), now in the third component; mirrors tree's `#logicalSlotChildren`. Static-literal selects + the FB-36 `<option selected>` initial-value path are unchanged. +2 regression tests. File: `components/select/select.class.js`. (Tokens Studio / color-app FB-98)
32
+ - **`<menu-ui>` — a closed declarative menu no longer takes layout space (FB-97).** The closed-state hide rule (`menu.css`) used a child combinator (`:scope > menu-item-ui`), so `.map()`/`repeat()`-rendered items — nested in the template engine's `display:contents`/`role="presentation"` wrapper span — escaped it on the initial (never-opened) render and laid out in flow, ballooning a compact menu to its widest item's width (a 388px file menu displaced a centered toolbar tab-row until a tab became un-clickable). Added wrapper-piercing descendant selectors (`:scope > [role="presentation"] menu-item-ui`, + `menu-divider-ui`); safe vs. the open state since `#show()` appendChild's the bare items into `[data-menu-popover]` (never the wrappers). Verified: a closed `.map()` menu collapses 388px → trigger width (~41px), items `display:none`, neighbour un-displaced; open still renders all items. The CSS instance of the FB-92/96/98 `display:contents` wrapper trap; makes the FB-92/95 "render menu items declaratively" guidance safe for compact menus. File: `components/menu/menu.css`. (Tokens Studio / color-app FB-97)
33
+ - **`button.yaml` `aria-label` prop marked `dynamic: true` — clears dogfood drift.** `aria-label` is a native ARIA attribute managed via `setAttribute` in `connected()` and deliberately kept out of `static properties` (would clobber native `ariaLabel` reflection via `installProps`). The `static-properties-vs-yaml` dogfood audit flagged the yaml declaration as a drift; `dynamic: true` is the established opt-out (same pattern as the `text` prop). `button.a2ui.json` regenerated (`aria-label` → `DynamicString $ref`); dogfood advisory count: 43 → 42.
34
+ - **`<button-ui>` trailing slot kbd-pill now tracks the button's label color on every variant.** A `<kbd-ui slot="trailing">` pill rendered `kbd-ui`'s dark default even on a primary (white-label) button. `kbd-ui` styles its own `color`/`background`/`border` inside `@scope (kbd-ui)`, which wins over the button's `[slot="trailing"]` rule by **scope proximity** (closer scope root) — so setting `color` (even `inherit`) on the slot is inert. Fix: the button overrides `kbd-ui`'s own *tokens* — `[slot="trailing"] { --kbd-fg: currentColor; --kbd-bg / --kbd-border: color-mix(in oklab, currentColor …, transparent) }`. `kbd-ui` declares those tokens at `:where(:scope)` (specificity 0,0,0), so the button's `[slot]` decls (0,1,0) win on **specificity — which the cascade resolves before proximity** — and the pill (text + translucent fill + border) tracks the label: white on primary/colored fills, dark on outline/ghost. Verified on a fresh serve (kbd computed `color` == button label across variants). File: `components/button/button.css`.
35
+
3
36
  ## [0.7.5] — 2026-06-02
4
37
 
5
38
  ### Fixed
@@ -66,8 +66,8 @@
66
66
  "Button"
67
67
  ],
68
68
  "slots": {
69
- "default (action-item-ui children)": {
70
- "description": "Child content region for the `default (action-item-ui children)` slot."
69
+ "default": {
70
+ "description": "Holds the list's `<action-item-ui>` children (the action items)."
71
71
  }
72
72
  },
73
73
  "states": [
@@ -28,8 +28,8 @@ events:
28
28
  type: object
29
29
  description: The triggering list-item element.
30
30
  slots:
31
- default (action-item-ui children):
32
- description: "Child content region for the `default (action-item-ui children)` slot."
31
+ default:
32
+ description: "Holds the list's `<action-item-ui>` children (the action items)."
33
33
  states:
34
34
  - name: idle
35
35
  description: Default, ready for interaction.
@@ -139,19 +139,35 @@ export class UIAgentArtifact extends UIElement {
139
139
  // To keep the Light-DOM approach consistent, we wrap existing children
140
140
  // into a body container if one isn't already present.
141
141
 
142
- // Capture any existing non-slotted children to put into the body wrapper
143
- const existingBodyNodes = Array.from(this.childNodes).filter((n) => {
144
- if (n.nodeType === 1) {
145
- const slot = /** @type {Element} */ (n).getAttribute?.('slot') || '';
146
- return slot !== 'primary' && slot !== 'secondary';
142
+ // Partition the author's children by LOGICAL slot, piercing the template
143
+ // engine's display:contents / role="presentation" wrapper spans. A consumer
144
+ // that interpolates the action buttons — `${actions.map(a => html`<button-ui
145
+ // slot="primary">…`)}` gets them nested in wrapper spans (core/template.js
146
+ // wrap()), so the old `:scope > [slot="primary"]` direct-child grab returned
147
+ // nothing and `innerHTML = ''` stranded them in the body. This is the
148
+ // wrapper-trap class (FB-92/96/98); walk + pierce so wrapped action buttons
149
+ // reach the header actions cluster. Wrappers are flattened (display:contents
150
+ // is layout-transparent); everything non-action becomes body.
151
+ const primaryBtns = [];
152
+ const secondaryBtns = [];
153
+ const bodyNodes = [];
154
+ const isWrapper = (el) =>
155
+ el.getAttribute('role') === 'presentation' || el.style?.display === 'contents';
156
+ const partition = (nodes) => {
157
+ for (const n of nodes) {
158
+ if (n.nodeType === 1) {
159
+ const el = /** @type {Element} */ (n);
160
+ if (isWrapper(el)) { partition(el.childNodes); continue; } // pierce; drop the wrapper
161
+ const slot = el.getAttribute('slot') || '';
162
+ if (slot === 'primary') { primaryBtns.push(el); continue; }
163
+ if (slot === 'secondary') { secondaryBtns.push(el); continue; }
164
+ }
165
+ bodyNodes.push(n);
147
166
  }
148
- return true;
149
- });
150
-
151
- // Clear — we'll rebuild, preserving slotted action buttons
152
- const primaryBtns = this.querySelectorAll(':scope > [slot="primary"]');
153
- const secondaryBtns = this.querySelectorAll(':scope > [slot="secondary"]');
167
+ };
168
+ partition(Array.from(this.childNodes));
154
169
 
170
+ // Clear — we'll rebuild around the captured (now-detached) children.
155
171
  this.innerHTML = '';
156
172
 
157
173
  // Header — keyboard-focusable button-style row that toggles collapsed.
@@ -197,7 +213,7 @@ export class UIAgentArtifact extends UIElement {
197
213
  // Body
198
214
  this.#bodyEl = document.createElement('div');
199
215
  this.#bodyEl.setAttribute('data-artifact-body', '');
200
- for (const n of existingBodyNodes) this.#bodyEl.appendChild(n);
216
+ for (const n of bodyNodes) this.#bodyEl.appendChild(n);
201
217
  if (this.collapsed) this.#bodyEl.hidden = true;
202
218
 
203
219
  this.append(this.#headerEl, this.#bodyEl);
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import '../../core/element.js';
3
+ import './agent-artifact.js';
4
+
5
+ const tick = () => new Promise((r) => queueMicrotask(r));
6
+
7
+ function mount(html) {
8
+ const wrap = document.createElement('div');
9
+ wrap.innerHTML = html;
10
+ document.body.appendChild(wrap);
11
+ return wrap.firstElementChild;
12
+ }
13
+
14
+ describe('agent-artifact-ui', () => {
15
+ beforeEach(() => { document.body.innerHTML = ''; });
16
+
17
+ // Baseline — static slot="primary"/"secondary" buttons route into the header
18
+ // actions cluster, and the default-slot content lands in the body.
19
+ it('routes static action buttons into the header actions cluster (not the body)', async () => {
20
+ const a = mount(`
21
+ <agent-artifact-ui title="Build">
22
+ <button-ui slot="primary">Run</button-ui>
23
+ <button-ui slot="secondary">Cancel</button-ui>
24
+ <p>Body content</p>
25
+ </agent-artifact-ui>
26
+ `);
27
+ await tick();
28
+ const actions = a.querySelector('[data-artifact-actions]');
29
+ const body = a.querySelector('[data-artifact-body]');
30
+ expect(actions).toBeTruthy();
31
+ expect(actions.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(2);
32
+ expect(body.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(0);
33
+ expect(body.textContent).toContain('Body content');
34
+ });
35
+
36
+ // Wrapper-trap regression (audit-wrapper-trap arm 2): action buttons rendered
37
+ // via `.map()`/`repeat()` arrive nested in the template engine's
38
+ // display:contents / role="presentation" wrapper spans. The old `:scope >
39
+ // [slot="primary"]` direct-child grab missed them, so `innerHTML = ''`
40
+ // stranded them in the body. They must reach the actions cluster.
41
+ it('routes WRAPPED (interpolated) action buttons into the actions cluster, not the body', async () => {
42
+ const a = mount(`
43
+ <agent-artifact-ui title="Build">
44
+ <span style="display:contents" role="presentation">
45
+ <span style="display:contents" role="presentation"><button-ui slot="primary">Run</button-ui></span>
46
+ <span style="display:contents" role="presentation"><button-ui slot="secondary">Cancel</button-ui></span>
47
+ </span>
48
+ <p>Body content</p>
49
+ </agent-artifact-ui>
50
+ `);
51
+ await tick();
52
+ const actions = a.querySelector('[data-artifact-actions]');
53
+ const body = a.querySelector('[data-artifact-body]');
54
+ expect(actions.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(2);
55
+ // The regression: before the wrapper-pierce fix these landed in the body.
56
+ expect(body.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(0);
57
+ expect(body.textContent).toContain('Body content');
58
+ // No empty wrapper spans left dangling — they're flattened, not re-appended.
59
+ expect(a.querySelectorAll('[role="presentation"]').length).toBe(0);
60
+ });
61
+ });
@@ -90,6 +90,7 @@ props:
90
90
  - muted
91
91
  - solid
92
92
  - outline
93
+ reflect: false # declarative presentational attribute — CSS/author-time read, non-reactive by design
93
94
  events: {}
94
95
  slots: {}
95
96
  states:
@@ -19,13 +19,29 @@
19
19
  * Properties:
20
20
  * padding — none | xs | sm | md | lg | xl (default 'md')
21
21
  * margin — none | xs | sm | md | lg | xl (default 'none')
22
+ *
23
+ * Values resolve to the parametric --a-{padding,margin}-{xs..xl} scale
24
+ * (foundation/space.css), unified with the global [padding]/[margin] attribute
25
+ * API: md = 24px @k=1, tracks --a-{padding,margin}-k. The `@bp` responsive
26
+ * path below emits the same tokens as the static block.css rules.
22
27
  */
23
28
 
24
29
  import { UIElement } from '../../core/element.js';
25
30
  import { parseResponsive, breakpoint } from '../../core/responsive.js';
26
31
 
27
- const _SPACE = { none: '0', xs: 'var(--a-space-1)', sm: 'var(--a-space-2)', md: 'var(--a-space-4)', lg: 'var(--a-space-6)', xl: 'var(--a-space-10)' };
28
- function _spaceToCss(v) { return _SPACE[v] ?? (/^\d+$/.test(v ?? '') ? `var(--a-space-${v})` : v ?? ''); }
32
+ const _NAMED = new Set(['xs', 'sm', 'md', 'lg', 'xl']);
33
+ /** Resolve a (responsive-parsed) padding/margin value to CSS for dimension
34
+ * `dim` ('padding' | 'margin'). Named rungs map to the parametric
35
+ * --a-{dim}-{xs..xl} scale (foundation/space.css) so this responsive JS path
36
+ * AGREES with the static block.css rules + the global [padding]/[margin]
37
+ * attribute API; bare integers stay literal --a-space-N rungs; 'none' → 0. */
38
+ function _spaceToCss(v, dim) {
39
+ if (v == null || v === '') return '';
40
+ if (v === 'none') return '0';
41
+ if (_NAMED.has(v)) return `var(--a-${dim}-${v})`;
42
+ if (/^\d+$/.test(v)) return `var(--a-space-${v})`;
43
+ return v;
44
+ }
29
45
 
30
46
  export class UIBlock extends UIElement {
31
47
  static properties = {
@@ -42,13 +58,13 @@ export class UIBlock extends UIElement {
42
58
  const bp = anyR ? breakpoint.value : '';
43
59
 
44
60
  if (padding?.includes('@')) {
45
- this.style.setProperty('--block-padding', _spaceToCss(parseResponsive(padding, bp)));
61
+ this.style.setProperty('--block-padding', _spaceToCss(parseResponsive(padding, bp), 'padding'));
46
62
  } else {
47
63
  this.style.removeProperty('--block-padding');
48
64
  }
49
65
 
50
66
  if (margin?.includes('@')) {
51
- this.style.setProperty('--block-margin', _spaceToCss(parseResponsive(margin, bp)));
67
+ this.style.setProperty('--block-margin', _spaceToCss(parseResponsive(margin, bp), 'margin'));
52
68
  } else {
53
69
  this.style.removeProperty('--block-margin');
54
70
  }
@@ -1,6 +1,6 @@
1
1
  @scope (block-ui) {
2
2
  :where(:scope) {
3
- --block-padding: var(--a-space-4);
3
+ --block-padding: var(--a-padding-md);
4
4
  --block-margin: 0;
5
5
  }
6
6
 
@@ -11,19 +11,24 @@
11
11
  margin: var(--block-margin);
12
12
  }
13
13
 
14
- /* ── Padding variants ── */
14
+ /* ── Padding variants the parametric --a-padding-{xs..xl} scale
15
+ (foundation/space.css), unified with the global [padding] attribute API
16
+ (api/sizing.css) so <block-ui padding="md"> == [padding="md"] == 24px @k=1,
17
+ and block-ui now tracks the --a-padding-k knob. Was a literal --a-space-N
18
+ scale (md=16px, k-independent) — see CHANGELOG 0.7.7 behavior change. ── */
15
19
  :scope[padding="none"] { --block-padding: 0; }
16
- :scope[padding="xs"] { --block-padding: var(--a-space-1); }
17
- :scope[padding="sm"] { --block-padding: var(--a-space-2); }
18
- :scope[padding="md"] { --block-padding: var(--a-space-4); }
19
- :scope[padding="lg"] { --block-padding: var(--a-space-6); }
20
- :scope[padding="xl"] { --block-padding: var(--a-space-10); }
20
+ :scope[padding="xs"] { --block-padding: var(--a-padding-xs); }
21
+ :scope[padding="sm"] { --block-padding: var(--a-padding-sm); }
22
+ :scope[padding="md"] { --block-padding: var(--a-padding-md); }
23
+ :scope[padding="lg"] { --block-padding: var(--a-padding-lg); }
24
+ :scope[padding="xl"] { --block-padding: var(--a-padding-xl); }
21
25
 
22
- /* ── Margin variants ── */
26
+ /* ── Margin variants the parametric --a-margin-{xs..xl} scale, unified
27
+ with the global [margin] attribute API (tracks --a-margin-k). ── */
23
28
  :scope[margin="none"] { --block-margin: 0; }
24
- :scope[margin="xs"] { --block-margin: var(--a-space-1); }
25
- :scope[margin="sm"] { --block-margin: var(--a-space-2); }
26
- :scope[margin="md"] { --block-margin: var(--a-space-4); }
27
- :scope[margin="lg"] { --block-margin: var(--a-space-6); }
28
- :scope[margin="xl"] { --block-margin: var(--a-space-10); }
29
+ :scope[margin="xs"] { --block-margin: var(--a-margin-xs); }
30
+ :scope[margin="sm"] { --block-margin: var(--a-margin-sm); }
31
+ :scope[margin="md"] { --block-margin: var(--a-margin-md); }
32
+ :scope[margin="lg"] { --block-margin: var(--a-margin-lg); }
33
+ :scope[margin="xl"] { --block-margin: var(--a-margin-xl); }
29
34
  }
@@ -20,8 +20,7 @@
20
20
  },
21
21
  "aria-label": {
22
22
  "description": "Accessible label for screen readers. Auto-set from `text` when text is non-empty; meaningful override for icon-only buttons.",
23
- "type": "string",
24
- "default": ""
23
+ "$ref": "common_types.json#/$defs/DynamicString"
25
24
  },
26
25
  "color": {
27
26
  "description": "Semantic intent — composes with [variant]. `<button-ui variant=\"solid\" color=\"danger\">` = filled destructive action; `<button-ui variant=\"outline\" color=\"success\">` = outlined success affordance.",
@@ -71,11 +71,12 @@ button-ui[color="danger"]:not([disabled]):hover {
71
71
 
72
72
  /* ── Trailing slot ── */
73
73
  --button-trailing-font-size: var(--a-ui-sm);
74
- /* kbd-pill tracks the button's OWN text color (currentColor) so it reads
75
- on any variant fillon a primary button the fixed --a-fg-muted gray
76
- was nearly invisible against the white label. Border is a translucent
77
- slice of the same color so the pill outline adapts too. */
78
- --button-trailing-fg: currentColor;
74
+ /* kbd-ui styles its color/bg/border inside `@scope (kbd-ui)`, which beats
75
+ this scope by PROXIMITYso setting `color`/`border` on [slot="trailing"]
76
+ here is inert for a kbd pill. The pill is recolored through kbd's OWN
77
+ tokens (--kbd-fg/-bg/-border) in the rule below instead. The
78
+ --button-trailing-* tokens remain the fallback for non-kbd trailing
79
+ content (badges, plain spans). */
79
80
  --button-trailing-border: color-mix(in oklab, currentColor 28%, transparent);
80
81
  --button-trailing-radius: var(--a-radius-sm);
81
82
  --button-trailing-px: var(--a-space-0-5);
@@ -142,12 +143,22 @@ button-ui[color="danger"]:not([disabled]):hover {
142
143
  order: 99;
143
144
  margin-inline-start: auto;
144
145
  font-size: var(--button-trailing-font-size);
145
- color: var(--button-trailing-fg);
146
+ color: inherit;
146
147
  font-family: inherit;
147
148
  border: 1px solid var(--button-trailing-border);
148
149
  border-radius: var(--button-trailing-radius);
149
150
  padding: 0 var(--button-trailing-px);
150
151
  line-height: 1;
152
+ /* Recolor a <kbd-ui slot="trailing"> pill via kbd's OWN tokens so it tracks
153
+ the button label color on every variant (primary white, ghost subtle,
154
+ colored fills). kbd-ui's `:scope { color: var(--kbd-fg) }` wins over the
155
+ `color: inherit` above by scope proximity, but its tokens are declared at
156
+ :where(:scope) (0,0,0) — these (0,1,0) decls win on specificity, which the
157
+ cascade resolves BEFORE proximity. currentColor here is the inherited
158
+ button label color. */
159
+ --kbd-fg: currentColor;
160
+ --kbd-bg: color-mix(in oklab, currentColor 16%, transparent);
161
+ --kbd-border: color-mix(in oklab, currentColor 30%, transparent);
151
162
  }
152
163
 
153
164
  /* :scope:active moved outside @scope — see Safari 17.x bug note at top. */
@@ -18,6 +18,7 @@ props:
18
18
  description: Accessible label for screen readers. Auto-set from `text` when text is non-empty; meaningful override for icon-only buttons.
19
19
  type: string
20
20
  default: ""
21
+ dynamic: true # native ARIA attr — managed in connected() (setAttribute), deliberately NOT in `static properties` (would clobber native ariaLabel reflection per the installProps memory)
21
22
  type:
22
23
  description: HTML button type (button, submit, reset)
23
24
  type: string
@@ -16,6 +16,7 @@ props:
16
16
  rung ("1"…"16").
17
17
  type: string
18
18
  default: md
19
+ reflect: false # declarative presentational attribute — CSS/author-time read, non-reactive by design
19
20
  draggable:
20
21
  description: Enables drag handle + cursor:grab. Wires the draggable trait; dispatches drag-end.
21
22
  type: boolean
@@ -55,8 +55,7 @@
55
55
  },
56
56
  "data": {
57
57
  "description": "JS property (set programmatically — `el.data = [...]`). An array of plain objects; each object's keys are named by the `x` and `y` attributes — e.g. `<chart-ui x=\"month\" y=\"revenue\">` consumes `[{month:'Jan', revenue:3200}, {month:'Feb', revenue:4100}]`. The Chart.js `{labels, datasets}` envelope is NOT chart-ui's API — passing it (or any non-array value) renders an empty chart. May also be supplied declaratively as a JSON-array `data=\"[…]\"` attribute, hydrated once at connect. Custom accessor on the element class, not a reflected attribute.",
58
- "type": "array",
59
- "default": []
58
+ "$ref": "common_types.json#/$defs/DynamicStringList"
60
59
  },
61
60
  "format": {
62
61
  "description": "Number-format mode applied to axis labels + value overlays + donut total + gauge value + treemap value + funnel value + internal tooltip. `abbr` is the legacy 1.2K / 3M format; `decimal` fixes 2 decimals; `currency` prefixes via `--chart-currency-prefix` token (default \"$\"); `percent` multiplies × 100 and adds a % suffix.",
@@ -23,7 +23,7 @@ export class UIChart extends UIElement {
23
23
  /** Color scheme */
24
24
  color: 'accent' | 'success' | 'warning' | 'danger' | 'info';
25
25
  /** JS property (set programmatically — `el.data = [...]`). An array of plain objects; each object's keys are named by the `x` and `y` attributes — e.g. `<chart-ui x="month" y="revenue">` consumes `[{month:'Jan', revenue:3200}, {month:'Feb', revenue:4100}]`. The Chart.js `{labels, datasets}` envelope is NOT chart-ui's API — passing it (or any non-array value) renders an empty chart. May also be supplied declaratively as a JSON-array `data="[…]"` attribute, hydrated once at connect. Custom accessor on the element class, not a reflected attribute. */
26
- data: unknown[];
26
+ data: string;
27
27
  /** Number-format mode applied to axis labels + value overlays + donut total + gauge value + treemap value + funnel value + internal tooltip. `abbr` is the legacy 1.2K / 3M format; `decimal` fixes 2 decimals; `currency` prefixes via `--chart-currency-prefix` token (default "$"); `percent` multiplies × 100 and adds a % suffix. */
28
28
  format: 'abbr' | 'decimal' | 'currency' | 'percent';
29
29
  /** When true, suppress the overlaid average line */
@@ -123,6 +123,7 @@ props:
123
123
  reflected attribute.
124
124
  type: array
125
125
  default: []
126
+ dynamic: true # JS-set collection prop with a custom setter — deliberately not in static properties
126
127
  events:
127
128
  chart-hover:
128
129
  description: >-
@@ -98,9 +98,6 @@
98
98
  "box": {
99
99
  "description": "Visual checkbox indicator"
100
100
  },
101
- "hint": {
102
- "description": "Help text below the label"
103
- },
104
101
  "label": {
105
102
  "description": "Label text beside the checkbox"
106
103
  }
@@ -58,8 +58,6 @@ events:
58
58
  slots:
59
59
  box:
60
60
  description: Visual checkbox indicator
61
- hint:
62
- description: Help text below the label
63
61
  label:
64
62
  description: Label text beside the checkbox
65
63
  states:
@@ -93,8 +93,7 @@
93
93
  },
94
94
  "options": {
95
95
  "description": "Programmatic option list. Array of {value, label, disabled?, group?} or grouped {label, options:[…]}. Alternative to declarative <option> / <optgroup> children.",
96
- "type": "array",
97
- "default": []
96
+ "$ref": "common_types.json#/$defs/DynamicStringList"
98
97
  },
99
98
  "placeholder": {
100
99
  "description": "Placeholder text in the input when value is empty",
@@ -84,6 +84,7 @@ props:
84
84
  <option> / <optgroup> children.
85
85
  type: array
86
86
  default: []
87
+ dynamic: true # JS-set collection prop with a custom setter — deliberately not in static properties
87
88
  open:
88
89
  description: Reflects popover open / closed state
89
90
  type: boolean
@@ -103,9 +103,6 @@
103
103
  },
104
104
  "list": {
105
105
  "description": "Container for command items and groups"
106
- },
107
- "search": {
108
- "description": "Search input with combobox role"
109
106
  }
110
107
  },
111
108
  "states": [
@@ -140,9 +140,27 @@ export class UICommand extends UIElement {
140
140
 
141
141
  // ── Parse declarative <option>/<optgroup> ──
142
142
 
143
+ // Wrapper-piercing walk for consumer-authored <option>/<optgroup> children.
144
+ // Mirrors select-ui's #logicalOptionChildren (FB-98): .map()/repeat()-rendered
145
+ // options arrive in display:contents/role="presentation" wrapper spans; a plain
146
+ // this.children iteration misses them (FB-98 class, 5th sighting in command-ui).
147
+ #logicalOptionChildren() {
148
+ const out = [];
149
+ const walk = (parent) => {
150
+ for (const ch of parent.children) {
151
+ if (ch.hasAttribute('slot')) continue; // component-stamped — skip
152
+ if (ch.tagName === 'OPTION' || ch.tagName === 'OPTGROUP') { out.push(ch); continue; }
153
+ if (ch.matches('[role="presentation"]') || ch.style?.display === 'contents') { walk(ch); continue; }
154
+ // unknown authored child — ignore (command-ui only uses option/optgroup)
155
+ }
156
+ };
157
+ walk(this);
158
+ return out;
159
+ }
160
+
143
161
  #parseOptions() {
144
162
  const items = [];
145
- for (const child of this.children) {
163
+ for (const child of this.#logicalOptionChildren()) {
146
164
  if (child.tagName === 'OPTGROUP') {
147
165
  const groupLabel = child.label;
148
166
  const group = { label: groupLabel, items: [] };
@@ -51,8 +51,6 @@ slots:
51
51
  description: Keyboard hint bar
52
52
  list:
53
53
  description: Container for command items and groups
54
- search:
55
- description: Search input with combobox role
56
54
  states:
57
55
  - name: idle
58
56
  description: Default, ready for interaction.
@@ -105,6 +105,7 @@ export class UIContextMenu extends UIElement {
105
105
  return [];
106
106
  }
107
107
  }
108
+ // wrapper-trap-ok: detects non-menu-item children as custom content; context-menu items are always static declarative children (right-click actions are not .map()'d)
108
109
  for (const child of this.children) {
109
110
  const tag = child.tagName.toLowerCase();
110
111
  if (tag !== 'menu-item-ui' && tag !== 'menu-divider-ui') return [child];
@@ -15,8 +15,7 @@
15
15
  "properties": {
16
16
  "items": {
17
17
  "description": "Optional JSON array of {term, description} — alternative to declarative <dt>/<dd> children",
18
- "type": "array",
19
- "default": []
18
+ "$ref": "common_types.json#/$defs/DynamicStringList"
20
19
  },
21
20
  "align": {
22
21
  "description": "Alignment for inline layout. `start` (default): term and value pack to the term column edge. `between`: term left, value right-aligned with `text-align: end`. `stretch`: value fills the remaining track width (e.g. for a `<slider-ui>` / `<select-ui>` / `<color-picker-ui>` as `<dd>` that should span the full row). Sets `justify-self: stretch` and `width: 100%` on `dd` so block-level form controls inside reach the column edge rather than shrink-wrapping to content.\n",
@@ -14,7 +14,7 @@ import { UIElement } from '../../core/element.js';
14
14
 
15
15
  export class UIDescriptionList extends UIElement {
16
16
  /** Optional JSON array of {term, description} — alternative to declarative <dt>/<dd> children */
17
- items: unknown[];
17
+ items: string;
18
18
  /** Alignment for inline layout. `start` (default): term and value pack to the term column edge. `between`: term left, value right-aligned with `text-align: end`. `stretch`: value fills the remaining track width (e.g. for a `<slider-ui>` / `<select-ui>` / `<color-picker-ui>` as `<dd>` that should span the full row). Sets `justify-self: stretch` and `width: 100%` on `dd` so block-level form controls inside reach the column edge rather than shrink-wrapping to content.
19
19
  */
20
20
  align: 'start' | 'between' | 'stretch';
@@ -30,6 +30,7 @@ props:
30
30
  description: Optional JSON array of {term, description} — alternative to declarative <dt>/<dd> children
31
31
  type: array
32
32
  default: []
33
+ dynamic: true # JS-set collection prop with a custom setter — deliberately not in static properties
33
34
  layout:
34
35
  description: "stacked: term above description. inline: term and description on one row."
35
36
  type: string
@@ -19,7 +19,7 @@
19
19
  /* ── Geometry ── */
20
20
  --drawer-width: 24rem;
21
21
  --drawer-height: 24rem;
22
- --drawer-inset: var(--a-space-4);
22
+ --drawer-inset: var(--a-inset);
23
23
  --drawer-header-pad: var(--drawer-inset);
24
24
  --drawer-footer-pad: var(--drawer-inset);
25
25
  --drawer-header-gap: var(--a-space-2);
@@ -245,6 +245,7 @@ export class UIField extends UIElement {
245
245
 
246
246
  /** The default-slot control — first child without a `slot` attribute. */
247
247
  #findControl() {
248
+ // wrapper-trap-ok: field control is always a single literal child (one <input>/<textarea>/<select>); interpolating the control via .map() is not a documented field pattern
248
249
  for (const ch of this.children) {
249
250
  if (!ch.hasAttribute('slot')) return ch;
250
251
  }
@@ -81,6 +81,7 @@ export class UIFields extends UIElement {
81
81
 
82
82
  #syncInline() {
83
83
  const inline = this.hasAttribute('inline');
84
+ // wrapper-trap-ok: fields-ui children are authored as static field-ui literals within a form group; dynamic .map() field collections use the programmatic API
84
85
  for (const child of this.children) {
85
86
  if (child.localName !== 'field-ui') continue;
86
87
  if (inline) {
@@ -23,6 +23,7 @@ props:
23
23
  in a 2-column hero. Sets grid-item alignment, NOT text alignment.
24
24
  type: string
25
25
  default: stretch
26
+ reflect: false # declarative presentational attribute — CSS/author-time read, non-reactive by design
26
27
  columnGap:
27
28
  description: Column gap override
28
29
  type: string
@@ -50,6 +51,7 @@ props:
50
51
  justify-items. Default stretch (items fill their column track).
51
52
  type: string
52
53
  default: stretch
54
+ reflect: false # declarative presentational attribute — CSS/author-time read, non-reactive by design
53
55
  minColumnWidth:
54
56
  description: >-
55
57
  Minimum track width for columns="auto-fit"/"auto-fill" (any CSS length,
@@ -40,8 +40,7 @@
40
40
  },
41
41
  "autocomplete": {
42
42
  "description": "Browser autofill behavior per HTML autocomplete spec. Routed via setAttribute to the host element. Common values: off, on, cc-number, cc-exp, cc-csc, cc-name, email, username, current-password, new-password, one-time-code, given-name, family-name, street-address, postal-code.",
43
- "type": "string",
44
- "default": ""
43
+ "$ref": "common_types.json#/$defs/DynamicString"
45
44
  },
46
45
  "component": {
47
46
  "const": "Input"
@@ -58,18 +57,7 @@
58
57
  },
59
58
  "inputmode": {
60
59
  "description": "Mobile keyboard hint per HTML inputmode spec. Routed via setAttribute to the host element. Values: text, decimal, numeric, tel, search, email, url.",
61
- "type": "string",
62
- "enum": [
63
- "text",
64
- "decimal",
65
- "numeric",
66
- "tel",
67
- "search",
68
- "email",
69
- "url",
70
- "none"
71
- ],
72
- "default": null
60
+ "$ref": "common_types.json#/$defs/DynamicString"
73
61
  },
74
62
  "label": {
75
63
  "description": "Inline label rendered as a leading caption inside the input chrome, between any prefix and the value. Wires aria-labelledby on the editable surface. For stacked label / hint / error compositions, wrap with field-ui.",