@adia-ai/web-components 0.8.38 → 0.8.40

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 (101) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/MIGRATION.md +243 -2
  3. package/components/agent-artifact/agent-artifact.css +1 -1
  4. package/components/agent-reasoning/agent-reasoning.css +2 -2
  5. package/components/agent-trace/agent-trace.css +3 -3
  6. package/components/alert/alert.css +1 -1
  7. package/components/avatar/avatar.css +1 -1
  8. package/components/badge/badge.css +1 -1
  9. package/components/button/button.css +6 -5
  10. package/components/calendar-picker/calendar-picker.css +5 -2
  11. package/components/card/card.css +3 -3
  12. package/components/chart/chart.a2ui.json +5 -5
  13. package/components/chart/chart.class.js +4 -4
  14. package/components/chart/chart.css +6 -6
  15. package/components/chart/chart.d.ts +2 -2
  16. package/components/chart/chart.yaml +3 -2
  17. package/components/chat-thread/chat-thread.css +1 -1
  18. package/components/code/code.css +6 -6
  19. package/components/combobox/combobox.css +8 -2
  20. package/components/command/command.a2ui.json +3 -0
  21. package/components/command/command.class.js +28 -6
  22. package/components/command/command.css +17 -6
  23. package/components/command/command.yaml +5 -0
  24. package/components/date-range-picker/date-range-picker.css +7 -1
  25. package/components/datetime-picker/datetime-picker.css +5 -1
  26. package/components/description-list/description-list.css +2 -2
  27. package/components/display-field/display-field.css +3 -3
  28. package/components/divider/divider.css +1 -1
  29. package/components/drawer/drawer.css +2 -2
  30. package/components/drilldown/drilldown.a2ui.json +3 -1
  31. package/components/drilldown/drilldown.class.js +12 -4
  32. package/components/drilldown/drilldown.css +9 -10
  33. package/components/drilldown/drilldown.yaml +2 -0
  34. package/components/empty-state/empty-state.css +2 -2
  35. package/components/field/field.css +1 -1
  36. package/components/heatmap/heatmap.css +2 -2
  37. package/components/inline-message/inline-message.css +1 -1
  38. package/components/input/input.css +5 -4
  39. package/components/integration-card/integration-card.css +4 -4
  40. package/components/kbd/kbd.css +1 -1
  41. package/components/list/list.css +2 -2
  42. package/components/modal/modal.class.js +68 -4
  43. package/components/nav/nav.a2ui.json +5 -0
  44. package/components/nav/nav.class.js +24 -4
  45. package/components/nav/nav.css +2 -2
  46. package/components/nav/nav.d.ts +2 -0
  47. package/components/nav/nav.yaml +18 -0
  48. package/components/nav-group/nav-group.css +3 -3
  49. package/components/nav-item/nav-item.css +2 -2
  50. package/components/option-card/option-card.css +1 -1
  51. package/components/otp-input/otp-input.css +1 -1
  52. package/components/page/page.css +2 -2
  53. package/components/pagination/pagination.css +1 -1
  54. package/components/pane/pane.css +1 -1
  55. package/components/progress-row/progress-row.css +2 -2
  56. package/components/qr-code/qr-code.a2ui.json +10 -10
  57. package/components/qr-code/qr-code.class.js +3 -3
  58. package/components/qr-code/qr-code.d.ts +10 -7
  59. package/components/qr-code/qr-code.yaml +13 -9
  60. package/components/range/range.css +1 -1
  61. package/components/rating/rating.css +1 -1
  62. package/components/richtext/richtext.css +3 -3
  63. package/components/segment/segment.css +5 -4
  64. package/components/select/select.class.js +30 -12
  65. package/components/select/select.css +14 -5
  66. package/components/spinner/spinner.css +1 -1
  67. package/components/stat/stat.css +3 -3
  68. package/components/step-progress/step-progress.css +1 -1
  69. package/components/stepper/stepper.css +2 -2
  70. package/components/swatch/swatch.css +8 -6
  71. package/components/table/table.a2ui.json +0 -10
  72. package/components/table/table.class.js +4 -4
  73. package/components/table/table.css +6 -9
  74. package/components/table/table.d.ts +0 -2
  75. package/components/table/table.yaml +0 -9
  76. package/components/table-toolbar/table-toolbar.css +1 -1
  77. package/components/tabs/tabs.css +1 -1
  78. package/components/tag/tag.css +2 -2
  79. package/components/tags-input/tags-input.css +1 -1
  80. package/components/textarea/textarea.css +1 -1
  81. package/components/time-picker/time-picker.css +1 -1
  82. package/components/timeline/timeline.css +4 -4
  83. package/components/toc/toc.css +1 -1
  84. package/components/toggle-group/toggle-group.class.js +13 -11
  85. package/components/toggle-group/toggle-group.css +14 -8
  86. package/components/toggle-group/toggle-group.d.ts +5 -0
  87. package/components/toggle-group/toggle-group.yaml +7 -0
  88. package/components/toggle-group/toggle-option.a2ui.json +5 -0
  89. package/components/toggle-group/toggle-option.yaml +14 -2
  90. package/components/tooltip/tooltip.css +1 -1
  91. package/components/tour/tour.css +2 -2
  92. package/components/tree/tree.css +1 -1
  93. package/custom-elements.json +101 -89
  94. package/dist/host.min.css +1 -1
  95. package/dist/host.sheet.js +1 -1
  96. package/dist/theme-provider.min.js +3 -3
  97. package/dist/web-components.min.css +1 -1
  98. package/dist/web-components.min.js +97 -94
  99. package/dist/web-components.sheet.js +1 -1
  100. package/package.json +1 -1
  101. package/styles/type/scale.css +6 -7
@@ -43,6 +43,7 @@
43
43
  */
44
44
 
45
45
  import { UIElement } from '../../core/element.js';
46
+ import { logicalSlotted } from '../../core/logical-children.js';
46
47
 
47
48
  export class UIModal extends UIElement {
48
49
  #bound = false;
@@ -51,6 +52,14 @@ export class UIModal extends UIElement {
51
52
  #closeTimer = null;
52
53
  #dialogRef = null;
53
54
 
55
+ // Guards `close` against firing twice for one dismissal — #animateClose's
56
+ // timer dispatches it directly (the authoritative path, gh#1295) and the
57
+ // native <dialog> 'close' listener also calls in as a redundant safety
58
+ // net for a consumer that closes the raw <dialog> element itself, and
59
+ // disconnected()'s teardown fallback (below) is a third path into the
60
+ // same guard. Reset whenever the dialog (re)opens.
61
+ #closeDispatched = false;
62
+
54
63
  // Monotonic, instance-agnostic counter for the fallback aria-labelledby
55
64
  // id stamped onto an author's [slot="heading"] (see render()) — multiple
56
65
  // <modal-ui> instances on one page must not collide on the same id.
@@ -93,6 +102,19 @@ export class UIModal extends UIElement {
93
102
  };
94
103
 
95
104
  #onDialogClose = () => {
105
+ // Native <dialog> 'close' — fires as a browser-QUEUED task (per the
106
+ // HTML spec, dialog.close() queues a task; it is not synchronous), so
107
+ // it can be lost if a consumer tears the listener down (removes this
108
+ // element) before that task runs. #animateClose's own timer is the
109
+ // authoritative dispatch path (gh#1295); this stays wired as a
110
+ // redundant net for a consumer calling the raw <dialog> element's
111
+ // own `.close()` directly.
112
+ this.#emitCloseOnce();
113
+ };
114
+
115
+ #emitCloseOnce = () => {
116
+ if (this.#closeDispatched) return;
117
+ this.#closeDispatched = true;
96
118
  this.open = false;
97
119
  this.#previousFocus?.focus();
98
120
  this.#previousFocus = null;
@@ -128,6 +150,32 @@ export class UIModal extends UIElement {
128
150
  clearTimeout(this.#closeTimer);
129
151
  this.#closeTimer = null;
130
152
  }
153
+ // Teardown fallback — the ROOT CAUSE of gh#1295 (adiav2 ADIA2-8581).
154
+ // Every internal close path (the native <dialog> 'close' listener,
155
+ // #animateClose's own timer) only ever runs from THIS element's own
156
+ // render effect (element.js's connectedCallback), which is set up as
157
+ // a signals.js effect that reacts to the `open` property write via a
158
+ // MICROTASK — the property setter itself only marks the signal dirty;
159
+ // it doesn't render synchronously. `disconnectedCallback()` disposes
160
+ // every effect (including that one) BEFORE disconnected() runs. A
161
+ // consumer whose own re-render removes <modal-ui> from the DOM in the
162
+ // SAME tick as setting `open = false` — an extremely common framework
163
+ // shape: a Confirm/Cancel handler flips reactive state that ALSO
164
+ // unmounts the modal on that same pass — tears down the render effect
165
+ // before it ever gets a chance to run, so #animateClose's timer is
166
+ // never even CREATED, let alone fired. No error, no warning: the
167
+ // ENTIRE close pipeline silently never starts, and any caller awaiting
168
+ // `close` (showConfirmDialog-style APIs) hangs forever. Confirmed via
169
+ // browser reproduction — vitest/happy-dom's synchronous effect
170
+ // scheduling masked this (see modal.test.js "parent-driven teardown").
171
+ // Disconnection always resolves an open-or-mid-close modal's `close`
172
+ // obligation as a last resort, regardless of which (if any) internal
173
+ // path got a chance to run first — #emitCloseOnce's guard makes this
174
+ // safe to call unconditionally alongside those other paths.
175
+ if (this.open || this.#closing || this.#dialogRef?.open) {
176
+ this.#emitCloseOnce();
177
+ }
178
+ this.#closing = false;
131
179
  this.#bound = false;
132
180
  this.#dialogRef = null;
133
181
  }
@@ -136,10 +184,15 @@ export class UIModal extends UIElement {
136
184
  // Read the computed `--modal-duration` (= --a-duration = 250ms unless a
137
185
  // consumer overrides) so the JS close timer matches the CSS exit animation
138
186
  // exactly — a stale/unset value clipped the animation ~50ms early
139
- // (bug class shared with swiper bug-29).
187
+ // (bug class shared with swiper bug-29). `parseFloat` alone silently
188
+ // drops a `s` vs `ms` unit (gh#1295 — a consumer-computed value observed
189
+ // as `.25s` parsed to 0.25, a 0.25ms timer instead of 250ms), so a
190
+ // trailing `s` (and not `ms`) is converted explicitly rather than
191
+ // trusted to already be milliseconds.
140
192
  const cs = getComputedStyle(this);
141
193
  const raw = cs.getPropertyValue('--modal-duration').trim();
142
- return parseFloat(raw) || 200;
194
+ const ms = /ms\s*$/.test(raw) ? parseFloat(raw) : /s\s*$/.test(raw) ? parseFloat(raw) * 1000 : parseFloat(raw);
195
+ return ms > 0 ? ms : 200;
143
196
  }
144
197
 
145
198
  render() {
@@ -195,8 +248,12 @@ export class UIModal extends UIElement {
195
248
  // dialog-modal pattern's accessible-name requirement. Re-evaluated
196
249
  // every render so a later `text=` set, or the heading being removed,
197
250
  // clears the reference instead of leaving it stale. (gh#947, from
198
- // PR #944.)
199
- const authoredHeading = header.querySelector(':scope > [slot="heading"]');
251
+ // PR #944.) `logicalSlotted` (not a `:scope >` direct-child query)
252
+ // pierces the template engine's display:contents/role=presentation
253
+ // wrapper spans, so a consumer that renders the heading conditionally
254
+ // (`${title ? html\`<span slot="heading">…\` : null}`) still gets an
255
+ // aria-labelledby — the wrapper-trap class, gh#1301.
256
+ const authoredHeading = logicalSlotted(header, 'heading')[0];
200
257
  if (!this.text && authoredHeading) {
201
258
  if (!authoredHeading.id) authoredHeading.id = `modal-heading-${++UIModal.#headingIdSeq}`;
202
259
  if (dialog.getAttribute('aria-labelledby') !== authoredHeading.id) {
@@ -261,6 +318,7 @@ export class UIModal extends UIElement {
261
318
  // See .claude/docs/BROWSER-COMPAT.md §3a (Flavor C).
262
319
  if (this.open && !dialog.open) {
263
320
  this.#closing = false;
321
+ this.#closeDispatched = false;
264
322
  this.#previousFocus = document.activeElement;
265
323
  dialog.showModal();
266
324
  void dialog.offsetHeight;
@@ -285,6 +343,12 @@ export class UIModal extends UIElement {
285
343
  this.#closing = false;
286
344
  dialog.removeAttribute('data-closing');
287
345
  if (dialog.open) dialog.close();
346
+ // Authoritative dispatch (gh#1295) — don't rely solely on the native
347
+ // <dialog> 'close' listener above, whose task can be lost if this
348
+ // element (or an ancestor) is torn down before it runs.
349
+ // #emitCloseOnce guards so a close event ALSO reaching
350
+ // #onDialogClose is a no-op.
351
+ this.#emitCloseOnce();
288
352
  }, this.#getDuration());
289
353
  }
290
354
 
@@ -31,6 +31,11 @@
31
31
  "type": "string",
32
32
  "default": ""
33
33
  },
34
+ "multiExpand": {
35
+ "description": "Opt out of the default single-expanded-group behavior. When unset, selecting a nav-item-ui collapses every nav-group-ui except the one containing the newly selected item (all, when the selection is ungrouped); manual multi-expansion still works up until the next selection.",
36
+ "type": "boolean",
37
+ "default": false
38
+ },
34
39
  "variant": {
35
40
  "description": "Visual treatment. primary = app sidebar; section = subnav rail.",
36
41
  "type": "string",
@@ -45,10 +45,14 @@ import { UIElement } from '../../core/element.js';
45
45
 
46
46
  export class UINav extends UIElement {
47
47
  static properties = {
48
- variant: { type: String, default: 'primary', reflect: true },
49
- collapsed: { type: Boolean, default: false, reflect: true },
50
- divider: { type: Boolean, default: false, reflect: true },
51
- heading: { type: String, default: '', reflect: true },
48
+ variant: { type: String, default: 'primary', reflect: true },
49
+ collapsed: { type: Boolean, default: false, reflect: true },
50
+ divider: { type: Boolean, default: false, reflect: true },
51
+ heading: { type: String, default: '', reflect: true },
52
+ // gh#1306: selection is the cleanup event for manually multi-expanded
53
+ // groups — default-on; opt out to let multiple groups stay open across
54
+ // selections (the operator's ruling, 2026-08-14).
55
+ multiExpand: { type: Boolean, default: false, reflect: true, attribute: 'multi-expand' },
52
56
  };
53
57
 
54
58
  static template = () => null;
@@ -99,6 +103,7 @@ export class UINav extends UIElement {
99
103
  if (item) {
100
104
  item.setAttribute('selected', '');
101
105
  this.#flushHoverState();
106
+ this.#collapseSiblingGroups(item);
102
107
  this.dispatchEvent(new CustomEvent('nav-select', {
103
108
  bubbles: true,
104
109
  detail: { item, text: item.text, value: item.value },
@@ -106,6 +111,21 @@ export class UINav extends UIElement {
106
111
  }
107
112
  }
108
113
 
114
+ // gh#1306: on selection, collapse every top-level group except the one
115
+ // containing the newly selected item (none, when the selection is
116
+ // ungrouped — every group collapses). Manual multi-expansion stays
117
+ // allowed right up until the next selection; [multi-expand] opts out
118
+ // entirely. Runs for every select() caller — click, keyboard, and the
119
+ // collapsed-rail popover path (nav-group.class.js's showPopover(),
120
+ // which also calls nav.select()).
121
+ #collapseSiblingGroups(item) {
122
+ if (this.multiExpand) return;
123
+ const activeGroup = item.closest('nav-group-ui');
124
+ for (const group of this.querySelectorAll(':scope > nav-group-ui')) {
125
+ if (group !== activeGroup) group.open = false;
126
+ }
127
+ }
128
+
109
129
  toggle() {
110
130
  if (this.variant === 'section') return; // no-op for section variant
111
131
  this.collapsed = !this.collapsed;
@@ -80,7 +80,7 @@
80
80
  font-weight: var(--nav-label-weight);
81
81
  color: var(--nav-label-fg);
82
82
  text-transform: uppercase;
83
- letter-spacing: 0.06em;
83
+ letter-spacing: var(--a-font-tracking-wider);
84
84
  white-space: nowrap;
85
85
  overflow: hidden;
86
86
  }
@@ -134,7 +134,7 @@
134
134
  font-weight: var(--nav-label-weight);
135
135
  color: var(--nav-label-fg);
136
136
  text-transform: uppercase;
137
- letter-spacing: 0.06em;
137
+ letter-spacing: var(--a-font-tracking-wider);
138
138
  white-space: nowrap;
139
139
  overflow: hidden;
140
140
  }
@@ -38,6 +38,8 @@ export class UINav extends UIElement {
38
38
  divider: boolean;
39
39
  /** Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only. */
40
40
  heading: string;
41
+ /** Opt out of the default single-expanded-group behavior. When unset, selecting a nav-item-ui collapses every nav-group-ui except the one containing the newly selected item (all, when the selection is ungrouped); manual multi-expansion still works up until the next selection. */
42
+ multiExpand: boolean;
41
43
  /** Visual treatment. primary = app sidebar; section = subnav rail. */
42
44
  variant: 'primary' | 'section';
43
45
 
@@ -39,6 +39,12 @@ props:
39
39
  type: string
40
40
  default: ''
41
41
  description: "Optional kicker label. Section variant renders it via ::before; primary uses it as aria-label only."
42
+ multiExpand:
43
+ type: boolean
44
+ default: false
45
+ reflect: true
46
+ attribute: multi-expand
47
+ description: "Opt out of the default single-expanded-group behavior. When unset, selecting a nav-item-ui collapses every nav-group-ui except the one containing the newly selected item (all, when the selection is ungrouped); manual multi-expansion still works up until the next selection."
42
48
 
43
49
  events:
44
50
  nav-select:
@@ -91,6 +97,18 @@ a2ui:
91
97
  or anchor) → <nav-ui>. If the user switches VIEWS within the same
92
98
  logical page → <tabs-ui>. Never use <nav-ui> as an in-page section
93
99
  switcher.
100
+ - >-
101
+ Group expansion on selection: selecting a <nav-item-ui> collapses
102
+ every <nav-group-ui> except the one containing the newly selected
103
+ item (all groups, when the selection is ungrouped). Manual
104
+ multi-expansion is allowed right up until the next selection. Set
105
+ [multi-expand] on <nav-ui> to opt out and keep multiple groups open
106
+ across selections. This clears the JS [open] state (and
107
+ aria-expanded) on every collapsed group in BOTH variants — primary
108
+ visually hides that group's children as a result; section variant's
109
+ CSS keeps children visible regardless of [open] (per the
110
+ section-variant cascade rule above), so only the accessibility
111
+ state resets there, with no visible change.
94
112
  - >-
95
113
  Anti-patterns: do NOT wrap <nav-ui> children in <col-ui> /
96
114
  <row-ui> — wrapping breaks selection bubbling + the variant
@@ -169,7 +169,7 @@
169
169
  background: var(--nav-group-badge-bg);
170
170
  padding: 1px var(--nav-group-badge-px);
171
171
  border-radius: var(--nav-group-badge-radius);
172
- line-height: 1.4;
172
+ line-height: var(--a-font-leading-normal);
173
173
  }
174
174
 
175
175
  [slot="header"] [slot="caret"] {
@@ -313,7 +313,7 @@ nav-group-ui [slot="popover-label"] {
313
313
  color: var(--nav-group-fg-muted);
314
314
  font-size: var(--a-ui-tiny);
315
315
  text-transform: uppercase;
316
- letter-spacing: 0.06em;
316
+ letter-spacing: var(--a-font-tracking-wider);
317
317
  }
318
318
 
319
319
  nav-group-ui [slot="popover"] [role="option"] {
@@ -364,7 +364,7 @@ nav-ui[variant="section"] > nav-group-ui:not([variant]) > [slot="header"] {
364
364
  font-weight: var(--a-weight-medium);
365
365
  color: var(--nav-group-fg-muted);
366
366
  text-transform: uppercase;
367
- letter-spacing: 0.06em;
367
+ letter-spacing: var(--a-font-tracking-wider);
368
368
  cursor: default;
369
369
  border-radius: 0;
370
370
  }
@@ -136,7 +136,7 @@ nav-item-ui[selected] [slot="icon"]:empty::before {
136
136
  background: var(--nav-item-badge-bg);
137
137
  padding: 1px var(--nav-item-badge-px);
138
138
  border-radius: var(--nav-item-badge-radius);
139
- line-height: 1.4;
139
+ line-height: var(--a-font-leading-normal);
140
140
  margin-inline-start: auto;
141
141
  }
142
142
 
@@ -147,7 +147,7 @@ nav-item-ui[selected] [slot="icon"]:empty::before {
147
147
  border: 1px solid var(--nav-item-trailing-border);
148
148
  border-radius: var(--nav-item-trailing-radius);
149
149
  padding: 0 var(--nav-item-trailing-px);
150
- line-height: 1.6;
150
+ line-height: var(--a-font-leading-loose);
151
151
  flex-shrink: 0;
152
152
  }
153
153
 
@@ -77,7 +77,7 @@ option-card-ui[checked] [slot="icon"] {
77
77
  --option-card-heading-size: var(--a-ui-size);
78
78
  --option-card-desc-color: var(--a-fg-muted);
79
79
  --option-card-desc-size: var(--a-ui-sm);
80
- --option-card-desc-line-height: 1.4;
80
+ --option-card-desc-line-height: var(--a-font-leading-relaxed);
81
81
  --option-card-icon-color: var(--md-sys-color-neutral-on-surface-variant);
82
82
  --option-card-icon-color-checked: var(--a-fg-strong);
83
83
  --option-card-icon-size: 1.5rem;
@@ -56,7 +56,7 @@
56
56
  color: var(--otp-input-fg);
57
57
  font-family: var(--otp-input-font);
58
58
  font-size: var(--otp-input-font-size);
59
- line-height: 1;
59
+ line-height: var(--a-font-leading-none);
60
60
  outline: none;
61
61
  transition: border-color var(--otp-input-duration) var(--otp-input-easing);
62
62
  /* Hide number spinner */
@@ -183,7 +183,7 @@
183
183
  :scope > :where(header, header-ui) > :is(text-ui[variant="display"], text-ui[variant="title"], text-ui[variant="heading"], text-ui[variant="subsection"]):not([slot]),
184
184
  :scope > :where(header, header-ui) > [slot="heading"] :is(h1, h2, h3, h4, h5, h6) {
185
185
  grid-row: 1;
186
- line-height: 1.3;
186
+ line-height: var(--a-font-leading-normal);
187
187
  margin: 0;
188
188
  }
189
189
 
@@ -210,7 +210,7 @@
210
210
  :scope > :where(header, header-ui) > :is(text-ui[variant="body"], text-ui[variant="caption"]):not([slot]) {
211
211
  grid-row: 2;
212
212
  grid-column: 1 / -1;
213
- line-height: 1.4;
213
+ line-height: var(--a-font-leading-relaxed);
214
214
  margin: 0;
215
215
  }
216
216
  :scope > :where(header, header-ui):has(> [slot="icon"]) > :is([slot="description"], p:not([slot]), small:not([slot])) { grid-column: 2 / -1; }
@@ -58,7 +58,7 @@
58
58
  font-size: var(--pagination-ellipsis-font);
59
59
  pointer-events: none;
60
60
  user-select: none;
61
- line-height: 1;
61
+ line-height: var(--a-font-leading-none);
62
62
  }
63
63
 
64
64
  /* ── variant="button" — square 1:1 cells via outline button-ui ──
@@ -141,7 +141,7 @@
141
141
  color: var(--pane-section-header-fg);
142
142
  padding-bottom: var(--pane-col-gap);
143
143
  text-transform: uppercase;
144
- letter-spacing: 0.06em;
144
+ letter-spacing: var(--a-font-tracking-wider);
145
145
  font-weight: var(--pane-section-header-weight);
146
146
  }
147
147
 
@@ -31,7 +31,7 @@
31
31
  grid-area: label;
32
32
  font-size: var(--progress-row-label-size);
33
33
  color: var(--progress-row-label-fg);
34
- line-height: 1.4;
34
+ line-height: var(--a-font-leading-normal);
35
35
  min-width: 0;
36
36
  overflow: hidden;
37
37
  text-overflow: ellipsis;
@@ -44,7 +44,7 @@
44
44
  color: var(--progress-row-meta-fg);
45
45
  font-variant-numeric: tabular-nums;
46
46
  justify-self: end;
47
- line-height: 1.4;
47
+ line-height: var(--a-font-leading-normal);
48
48
  }
49
49
 
50
50
  progress-ui {
@@ -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/QRCode.json",
4
4
  "title": "QRCode",
5
- "description": "Inline QR code generator — renders a scannable code as inline SVG.\nTwo modes:\n\n**A. Auto-encode (default).** Set `[value]` to a string (URL, plain\ntext, share link, 2FA secret). The component encodes the data with\nthe built-in zero-deps QR encoder (byte mode, ECC level configurable,\nversions 1–10 up to ~150 byte chars) and renders an SVG.\n\n**B. Bring-your-own matrix.** Set `[matrix]` to a JSON string of a\n2-D 0/1 array (`[[1,0,1,...],[...],...]`). The component renders the\nsupplied matrix directly without invoking its own encoder. Use this\nwhen you need versions 11–40, kanji/alphanumeric mode, or any\nencoder feature beyond the built-in. When both `[value]` and\n`[matrix]` are set, the matrix wins (BYO override).\n\nOutput is a single `<svg>` with `viewBox=\"0 0 N N\"`. Crisp-edges\nrendering preserves the scannable cell boundaries at any size.\nSizing is controlled by the `[size]` prop (host CSS-pixel dimension);\ninternal cell size is computed from `size / (matrix-size + 2 ·\nmargin)`. ARIA: `role=\"img\"` with `aria-label` (default \"QR code\"\nor the `[label]` prop).\n",
5
+ "description": "Inline QR code generator — renders a scannable code as inline SVG.\nTwo modes:\n\n**A. Auto-encode (default).** Set `[value]` to a string (URL, plain\ntext, share link, 2FA secret). The component encodes the data with\nthe built-in zero-deps QR encoder (byte mode, ECC level configurable,\nversions 1–10 up to ~150 byte chars) and renders an SVG.\n\n**B. Bring-your-own matrix.** Set `[matrix]` to a JSON string of a\n2-D 0/1 array (`[[1,0,1,...],[...],...]`). The component renders the\nsupplied matrix directly without invoking its own encoder. Use this\nwhen you need versions 11–40, kanji/alphanumeric mode, or any\nencoder feature beyond the built-in. When both `[value]` and\n`[matrix]` are set, the matrix wins (BYO override).\n\nOutput is a single `<svg>` with `viewBox=\"0 0 N N\"`. Crisp-edges\nrendering preserves the scannable cell boundaries at any size.\nSizing is controlled by the `[size]` prop (host CSS-pixel dimension);\ninternal cell size is computed from `size / (matrix-size + 2 ·\nquiet-zone)`. ARIA: `role=\"img\"` with `aria-label` (default \"QR code\"\nor the `[label]` prop).\n",
6
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -42,18 +42,18 @@
42
42
  "type": "string",
43
43
  "default": "QR code"
44
44
  },
45
- "margin": {
46
- "description": "Quiet zone in cells around the QR pattern. QR spec requires a\nminimum 4-cell margin for reliable scanning; smaller values may\nnot scan on some devices. Default `4`.\n",
47
- "type": "number",
48
- "default": 4
49
- },
50
45
  "matrix": {
51
46
  "description": "BYO precomputed bit matrix as JSON — a 2-D array of 0/1\n(`[[1,0,...],[0,1,...],...]`). When set, the built-in encoder is\nbypassed and this matrix renders directly. Useful for QR variants\nthe built-in doesn't support (versions 11–40, alphanumeric mode,\nkanji, etc.) — bring your own encoder, pass the matrix here.\n",
52
47
  "type": "string",
53
48
  "default": ""
54
49
  },
50
+ "quietZone": {
51
+ "description": "Quiet zone in cells around the QR pattern. QR spec requires a\nminimum 4-cell quiet zone for reliable scanning; smaller values\nmay not scan on some devices. Default `4`. (Renamed from\n`[margin]` — that name collided with the framework's global\n`[margin]` Scale attribute; `margin` on `qr-code-ui` now means\nthe global spacing attribute, per ADR-0053.)\n",
52
+ "type": "number",
53
+ "default": 4
54
+ },
55
55
  "size": {
56
- "description": "Host display size in CSS pixels (width and height; the SVG is\nalways square). Default 200. Internal cell size is computed from\nthis divided by `matrix-size + 2·margin`.\n",
56
+ "description": "Host display size in CSS pixels (width and height; the SVG is\nalways square). Default 200. Internal cell size is computed from\nthis divided by `matrix-size + 2·quiet-zone`.\n",
57
57
  "type": "number",
58
58
  "default": 200
59
59
  },
@@ -75,9 +75,9 @@
75
75
  "wrong": "<qr-code-ui value=\"https://very.long.url.that.exceeds.150.bytes.of.utf8.encoded.length...\"></qr-code-ui>\n"
76
76
  },
77
77
  {
78
- "fix": "<qr-code-ui value=\"...\" margin=\"4\"></qr-code-ui>\n",
79
- "why": "Zero margin breaks scanning. QR spec requires a 4-cell quiet\nzone for reliable detection.\n",
80
- "wrong": "<qr-code-ui value=\"...\" margin=\"0\"></qr-code-ui>\n"
78
+ "fix": "<qr-code-ui value=\"...\" quiet-zone=\"4\"></qr-code-ui>\n",
79
+ "why": "Zero quiet zone breaks scanning. QR spec requires a 4-cell quiet\nzone for reliable detection.\n",
80
+ "wrong": "<qr-code-ui value=\"...\" quiet-zone=\"0\"></qr-code-ui>\n"
81
81
  }
82
82
  ],
83
83
  "category": "display",
@@ -33,7 +33,7 @@ export class UIQRCode extends UIElement {
33
33
  matrix: { type: String, default: '', reflect: true },
34
34
  size: { type: Number, default: 200, reflect: true },
35
35
  errorCorrection: { type: String, default: 'M', reflect: true, attribute: 'error-correction' },
36
- margin: { type: Number, default: 4, reflect: true },
36
+ quietZone: { type: Number, default: 4, reflect: true, attribute: 'quiet-zone' },
37
37
  color: { type: String, default: '', reflect: true },
38
38
  background: { type: String, default: '', reflect: true },
39
39
  label: { type: String, default: 'QR code', reflect: true },
@@ -91,7 +91,7 @@ export class UIQRCode extends UIElement {
91
91
  // Cheap idempotence: compute a signature of inputs, skip re-render
92
92
  // if nothing changed (encoding is non-trivial — version selection +
93
93
  // RS ECC + 8 mask evaluations).
94
- const sig = `${this.value}|${this.matrix}|${this.size}|${this.errorCorrection}|${this.margin}|${this.color}|${this.background}`;
94
+ const sig = `${this.value}|${this.matrix}|${this.size}|${this.errorCorrection}|${this.quietZone}|${this.color}|${this.background}`;
95
95
  if (sig === this.#lastSig && this.querySelector('svg')) return;
96
96
  this.#lastSig = sig;
97
97
 
@@ -106,7 +106,7 @@ export class UIQRCode extends UIElement {
106
106
 
107
107
  const svg = matrixToSVG(matrix, {
108
108
  cellSize: 8, // arbitrary internal unit; host CSS controls actual size via [size]
109
- margin: Number.isFinite(this.margin) ? this.margin : 4,
109
+ margin: Number.isFinite(this.quietZone) ? this.quietZone : 4,
110
110
  // Hardcoded fallback to black-on-white — the scannable convention.
111
111
  // Theme-aware `currentColor` produces light-on-dark in dark mode,
112
112
  // which most phone cameras (iPhone Camera in particular) refuse to
@@ -18,7 +18,7 @@ Output is a single `<svg>` with `viewBox="0 0 N N"`. Crisp-edges
18
18
  rendering preserves the scannable cell boundaries at any size.
19
19
  Sizing is controlled by the `[size]` prop (host CSS-pixel dimension);
20
20
  internal cell size is computed from `size / (matrix-size + 2 ·
21
- margin)`. ARIA: `role="img"` with `aria-label` (default "QR code"
21
+ quiet-zone)`. ARIA: `role="img"` with `aria-label` (default "QR code"
22
22
  or the `[label]` prop).
23
23
 
24
24
  *
@@ -58,11 +58,6 @@ from more damage at the cost of QR-version size growth.
58
58
  better AT context (e.g. "Share link QR code", "2FA setup QR").
59
59
  */
60
60
  label: string;
61
- /** Quiet zone in cells around the QR pattern. QR spec requires a
62
- minimum 4-cell margin for reliable scanning; smaller values may
63
- not scan on some devices. Default `4`.
64
- */
65
- margin: number;
66
61
  /** BYO precomputed bit matrix as JSON — a 2-D array of 0/1
67
62
  (`[[1,0,...],[0,1,...],...]`). When set, the built-in encoder is
68
63
  bypassed and this matrix renders directly. Useful for QR variants
@@ -70,9 +65,17 @@ the built-in doesn't support (versions 11–40, alphanumeric mode,
70
65
  kanji, etc.) — bring your own encoder, pass the matrix here.
71
66
  */
72
67
  matrix: string;
68
+ /** Quiet zone in cells around the QR pattern. QR spec requires a
69
+ minimum 4-cell quiet zone for reliable scanning; smaller values
70
+ may not scan on some devices. Default `4`. (Renamed from
71
+ `[margin]` — that name collided with the framework's global
72
+ `[margin]` Scale attribute; `margin` on `qr-code-ui` now means
73
+ the global spacing attribute, per ADR-0053.)
74
+ */
75
+ quietZone: number;
73
76
  /** Host display size in CSS pixels (width and height; the SVG is
74
77
  always square). Default 200. Internal cell size is computed from
75
- this divided by `matrix-size + 2·margin`.
78
+ this divided by `matrix-size + 2·quiet-zone`.
76
79
  */
77
80
  size: number;
78
81
  /** The string to encode (URL / text / share link / 2FA seed). When
@@ -25,7 +25,7 @@ description: |
25
25
  rendering preserves the scannable cell boundaries at any size.
26
26
  Sizing is controlled by the `[size]` prop (host CSS-pixel dimension);
27
27
  internal cell size is computed from `size / (matrix-size + 2 ·
28
- margin)`. ARIA: `role="img"` with `aria-label` (default "QR code"
28
+ quiet-zone)`. ARIA: `role="img"` with `aria-label` (default "QR code"
29
29
  or the `[label]` prop).
30
30
  props:
31
31
  value:
@@ -50,7 +50,7 @@ props:
50
50
  description: |
51
51
  Host display size in CSS pixels (width and height; the SVG is
52
52
  always square). Default 200. Internal cell size is computed from
53
- this divided by `matrix-size + 2·margin`.
53
+ this divided by `matrix-size + 2·quiet-zone`.
54
54
  type: number
55
55
  default: 200
56
56
  reflect: true
@@ -64,14 +64,18 @@ props:
64
64
  enum: [L, M, Q, H]
65
65
  reflect: true
66
66
  attribute: error-correction
67
- margin:
67
+ quietZone:
68
68
  description: |
69
69
  Quiet zone in cells around the QR pattern. QR spec requires a
70
- minimum 4-cell margin for reliable scanning; smaller values may
71
- not scan on some devices. Default `4`.
70
+ minimum 4-cell quiet zone for reliable scanning; smaller values
71
+ may not scan on some devices. Default `4`. (Renamed from
72
+ `[margin]` — that name collided with the framework's global
73
+ `[margin]` Scale attribute; `margin` on `qr-code-ui` now means
74
+ the global spacing attribute, per ADR-0053.)
72
75
  type: number
73
76
  default: 4
74
77
  reflect: true
78
+ attribute: quiet-zone
75
79
  color:
76
80
  description: |
77
81
  Foreground (dark-cell) color. Any CSS color string (hex, rgb,
@@ -125,7 +129,7 @@ a2ui:
125
129
  reason: "Encoder capacity contract."
126
130
  - rule: "When QR sits over a non-white surface, set [background] to a solid color. White-on-image QR backgrounds are unreliable for scanners — quiet-zone contrast against the dark cells is the load-bearing visual."
127
131
  reason: "Scannability constraint."
128
- - rule: "The [margin] (quiet zone) defaults to 4 cells per QR spec. Smaller margins may scan on some devices but not others; do not go below 2."
132
+ - rule: "The [quiet-zone] defaults to 4 cells per QR spec. Smaller values may scan on some devices but not others; do not go below 2."
129
133
  reason: "Quiet-zone requirement."
130
134
  anti_patterns:
131
135
  - wrong: |
@@ -140,12 +144,12 @@ anti_patterns:
140
144
  with a BYO encoder library + pass the [matrix]:
141
145
  `<qr-code-ui matrix="[[1,0,1,...],...]"></qr-code-ui>`
142
146
  - wrong: |
143
- <qr-code-ui value="..." margin="0"></qr-code-ui>
147
+ <qr-code-ui value="..." quiet-zone="0"></qr-code-ui>
144
148
  why: |
145
- Zero margin breaks scanning. QR spec requires a 4-cell quiet
149
+ Zero quiet zone breaks scanning. QR spec requires a 4-cell quiet
146
150
  zone for reliable detection.
147
151
  fix: |
148
- <qr-code-ui value="..." margin="4"></qr-code-ui>
152
+ <qr-code-ui value="..." quiet-zone="4"></qr-code-ui>
149
153
  examples:
150
154
  - name: default
151
155
  description: Plain URL encoding at default size + ECC-M.
@@ -69,7 +69,7 @@ range-ui:not([disabled]) [slot="field"]:hover [data-layer="fill"] {
69
69
  /* font-family floor — see segment.css rationale. */
70
70
  font-family: var(--range-font-family);
71
71
  font-size: var(--range-font-size);
72
- line-height: 1;
72
+ line-height: var(--a-font-leading-none);
73
73
  transition:
74
74
  border-color var(--range-duration) var(--range-easing),
75
75
  background var(--range-duration) var(--range-easing);
@@ -32,7 +32,7 @@ rating-ui:hover [data-rating-symbol] {
32
32
  gap: var(--rating-gap);
33
33
  cursor: pointer;
34
34
  user-select: none;
35
- line-height: 1;
35
+ line-height: var(--a-font-leading-none);
36
36
  }
37
37
 
38
38
  :scope[readonly],
@@ -164,7 +164,7 @@
164
164
  font-weight: var(--richtext-h4-weight);
165
165
  color: var(--richtext-fg-muted);
166
166
  text-transform: uppercase;
167
- letter-spacing: 0.04em;
167
+ letter-spacing: var(--a-font-tracking-wide);
168
168
  margin: var(--richtext-h4-margin-top) 0 var(--richtext-h4-margin-bottom);
169
169
  }
170
170
 
@@ -173,7 +173,7 @@
173
173
  font-weight: var(--richtext-h4-weight);
174
174
  color: var(--richtext-fg-muted);
175
175
  text-transform: uppercase;
176
- letter-spacing: 0.06em;
176
+ letter-spacing: var(--a-font-tracking-wider);
177
177
  margin: var(--richtext-h4-margin-top) 0 var(--richtext-h4-margin-bottom);
178
178
  }
179
179
 
@@ -212,7 +212,7 @@
212
212
  padding: var(--richtext-pre-inset);
213
213
  overflow-x: auto;
214
214
  font-size: var(--richtext-code-size);
215
- line-height: 1.5;
215
+ line-height: var(--a-font-leading-relaxed);
216
216
  margin: var(--richtext-pre-my) 0;
217
217
  }
218
218
 
@@ -23,9 +23,10 @@ segment-ui[selected] {
23
23
  `line-height: 1` is deliberate, but the label's ::after clips to its
24
24
  own line box, so inheriting 1 makes half-leading negative and shears
25
25
  descenders (g/j/p/q/y, parentheses) by ~0.93px at the default size —
26
- gh#906. Snug (1.3) contains the font's content box at every [size];
27
- mirrors --button-label-line-height. */
28
- --segment-label-line-height: var(--a-leading-snug);
26
+ gh#906. Normal (1.35) contains the font's content box at every
27
+ [size]; mirrors --button-label-line-height. Value-nearest migration
28
+ from the retired --a-leading-snug (1.3) — ADR-0052. */
29
+ --segment-label-line-height: var(--a-font-leading-normal);
29
30
 
30
31
  /* ── Colors ── */
31
32
  --segment-fg: var(--a-ui-text-subtle);
@@ -60,7 +61,7 @@ segment-ui[selected] {
60
61
  font-family: var(--segment-font-family);
61
62
  font-size: var(--segment-font-size);
62
63
  font-weight: var(--segment-font-weight);
63
- line-height: 1;
64
+ line-height: var(--a-font-leading-none);
64
65
  color: var(--segment-fg);
65
66
  white-space: nowrap;
66
67
  overflow: hidden;