@adia-ai/web-components 0.6.50 → 0.7.0

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 (100) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/components/action-list/action-list.css +1 -1
  3. package/components/agent-artifact/agent-artifact.class.js +10 -10
  4. package/components/agent-artifact/agent-artifact.css +1 -1
  5. package/components/agent-reasoning/agent-reasoning.class.js +51 -0
  6. package/components/agent-reasoning/agent-reasoning.css +49 -22
  7. package/components/alert/alert.class.js +8 -1
  8. package/components/alert/alert.css +13 -1
  9. package/components/avatar/avatar.a2ui.json +2 -14
  10. package/components/avatar/avatar.class.js +3 -15
  11. package/components/avatar/avatar.d.ts +2 -4
  12. package/components/avatar/avatar.yaml +1 -18
  13. package/components/breadcrumb/breadcrumb.css +4 -1
  14. package/components/button/button.a2ui.json +3 -0
  15. package/components/button/button.css +14 -3
  16. package/components/button/button.yaml +5 -0
  17. package/components/calendar-grid/calendar-grid.css +1 -1
  18. package/components/calendar-picker/calendar-picker.css +5 -2
  19. package/components/chart/chart.a2ui.json +0 -18
  20. package/components/chart/chart.class.js +8 -50
  21. package/components/chart/chart.css +1 -15
  22. package/components/chart/chart.d.ts +0 -4
  23. package/components/chart/chart.yaml +0 -24
  24. package/components/color-input/color-input.css +4 -1
  25. package/components/combobox/combobox.class.js +11 -0
  26. package/components/combobox/combobox.css +8 -0
  27. package/components/date-range-picker/date-range-picker.class.js +5 -1
  28. package/components/date-range-picker/date-range-picker.css +12 -2
  29. package/components/datetime-picker/datetime-picker.class.js +3 -0
  30. package/components/datetime-picker/datetime-picker.css +16 -2
  31. package/components/empty-state/empty-state.css +11 -4
  32. package/components/field/field.css +17 -6
  33. package/components/grid/grid.a2ui.json +5 -0
  34. package/components/grid/grid.class.js +16 -6
  35. package/components/grid/grid.css +17 -3
  36. package/components/grid/grid.d.ts +2 -0
  37. package/components/grid/grid.yaml +9 -0
  38. package/components/heatmap/heatmap.class.js +9 -3
  39. package/components/heatmap/heatmap.css +19 -2
  40. package/components/image/image.css +4 -1
  41. package/components/input/input.css +1 -1
  42. package/components/integration-card/integration-card.class.js +31 -7
  43. package/components/integration-card/integration-card.test.js +12 -1
  44. package/components/kbd/kbd.a2ui.json +3 -2
  45. package/components/kbd/kbd.css +7 -4
  46. package/components/kbd/kbd.d.ts +2 -2
  47. package/components/kbd/kbd.yaml +2 -1
  48. package/components/list/list.class.js +8 -1
  49. package/components/menu/menu.css +4 -1
  50. package/components/modal/modal.class.js +10 -1
  51. package/components/modal/modal.css +9 -0
  52. package/components/option-card/option-card.a2ui.json +3 -0
  53. package/components/option-card/option-card.css +44 -19
  54. package/components/option-card/option-card.yaml +5 -0
  55. package/components/otp-input/otp-input.css +25 -10
  56. package/components/page/page.css +64 -11
  57. package/components/pagination/pagination.class.js +1 -1
  58. package/components/pagination/pagination.css +9 -1
  59. package/components/pipeline-status/pipeline-status.css +6 -0
  60. package/components/popover/popover.css +12 -1
  61. package/components/preview/preview.css +30 -3
  62. package/components/progress-row/progress-row.css +3 -1
  63. package/components/qr-code/qr-code.css +4 -1
  64. package/components/segmented/segmented.css +4 -1
  65. package/components/select/select.a2ui.json +1 -1
  66. package/components/select/select.class.js +63 -7
  67. package/components/select/select.css +18 -0
  68. package/components/select/select.yaml +9 -2
  69. package/components/stack/stack.a2ui.json +12 -1
  70. package/components/stack/stack.d.ts +2 -2
  71. package/components/stack/stack.yaml +13 -1
  72. package/components/stat/stat.a2ui.json +5 -0
  73. package/components/stat/stat.css +55 -0
  74. package/components/stat/stat.d.ts +2 -0
  75. package/components/stat/stat.js +4 -0
  76. package/components/stat/stat.yaml +9 -0
  77. package/components/swiper/swiper.class.js +14 -6
  78. package/components/switch/switch.css +13 -0
  79. package/components/table/table.a2ui.json +2 -2
  80. package/components/table/table.css +13 -1
  81. package/components/table/table.yaml +2 -2
  82. package/components/time-picker/time-picker.css +4 -1
  83. package/components/timeline/timeline.class.js +3 -3
  84. package/components/timeline/timeline.css +23 -5
  85. package/components/toggle-group/toggle-group.css +4 -1
  86. package/components/toggle-scheme/toggle-scheme.css +4 -1
  87. package/dist/web-components.min.css +1 -1
  88. package/dist/web-components.min.js +81 -81
  89. package/package.json +3 -3
  90. package/styles/api/layout.css +7 -0
  91. package/styles/api/text.css +9 -5
  92. package/styles/index.css +11 -2
  93. package/styles/prose.css +8 -0
  94. package/styles/resets.css +5 -5
  95. package/styles/themes.css +8 -1
  96. package/styles/tokens.css +3 -3
  97. package/styles/type/elements.css +73 -0
  98. package/styles/type/roles.css +14 -49
  99. package/styles/type/scale.css +0 -5
  100. package/styles/typography.css +3 -3
@@ -28,9 +28,23 @@
28
28
  :scope[columns="5"] { grid-auto-flow: row; grid-template-columns: repeat(5, 1fr); grid-auto-columns: auto; }
29
29
  :scope[columns="6"] { grid-auto-flow: row; grid-template-columns: repeat(6, 1fr); grid-auto-columns: auto; }
30
30
 
31
- /* Responsive presets */
32
- :scope[columns="auto-fill"] { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }
33
- :scope[columns="auto-fit"] { grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); }
31
+ /* Responsive presets — the minmax() floor reads [min-column-width] via
32
+ --grid-min-col (set inline by grid.class.js), defaulting to 12rem.
33
+ MUST also flip to grid-auto-flow:row + grid-auto-columns:auto (like the
34
+ numeric rules) — the base :scope default is grid-auto-flow:column +
35
+ grid-auto-columns:1fr, which makes items flow into IMPLICIT 1fr columns
36
+ instead of wrapping into the auto-fit row tracks (the cause of the
37
+ "240px 77px 77px" sliver cram). */
38
+ :scope[columns="auto-fill"] {
39
+ grid-auto-flow: row;
40
+ grid-auto-columns: auto;
41
+ grid-template-columns: repeat(auto-fill, minmax(var(--grid-min-col, 12rem), 1fr));
42
+ }
43
+ :scope[columns="auto-fit"] {
44
+ grid-auto-flow: row;
45
+ grid-auto-columns: auto;
46
+ grid-template-columns: repeat(auto-fit, minmax(var(--grid-min-col, 12rem), 1fr));
47
+ }
34
48
 
35
49
  /* Column span — children can span multiple columns */
36
50
  & > [span="2"] { grid-column: span 2; }
@@ -19,6 +19,8 @@ export class UIGrid extends UIElement {
19
19
  columns: string;
20
20
  /** Grid gap. Accepts numeric space-scale values (0–12) or named sizes (xs/sm/md/lg/xl). Responsive notation supported: "2 4@md" = 2 below md, 4 from md upward. */
21
21
  gap: string;
22
+ /** Minimum track width for columns="auto-fit"/"auto-fill" (any CSS length, e.g. "240px", "16rem"). Sets the minmax() floor so cards don't shrink below it before wrapping; unset uses the 12rem default. No effect on numeric columns. */
23
+ minColumnWidth: string;
22
24
  /** Row gap override */
23
25
  rowGap: string;
24
26
  }
@@ -35,6 +35,15 @@ props:
35
35
  md, 4 from md upward.
36
36
  type: string
37
37
  default: md
38
+ minColumnWidth:
39
+ description: >-
40
+ Minimum track width for columns="auto-fit"/"auto-fill" (any CSS length,
41
+ e.g. "240px", "16rem"). Sets the minmax() floor so cards don't shrink
42
+ below it before wrapping; unset uses the 12rem default. No effect on
43
+ numeric columns.
44
+ type: string
45
+ default: ""
46
+ attribute: min-column-width
38
47
  rowGap:
39
48
  description: Row gap override
40
49
  type: string
@@ -181,15 +181,21 @@ export class UIHeatmap extends UIElement {
181
181
  labels.appendChild(span);
182
182
  }
183
183
  }
184
- labels.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
184
+ // day-grid month labels align to the fixed-size cell columns.
185
+ labels.style.gridTemplateColumns = `repeat(${cols}, var(--heatmap-cell-min-size, 0.75rem))`;
185
186
  this.appendChild(labels);
186
187
  }
187
188
 
188
189
  // ── Grid of cells ──
190
+ // day-grid keeps FIXED cell tracks (host scrolls horizontally when the
191
+ // 52-week grid exceeds the container); matrix/density use minmax(0,1fr)
192
+ // so they fit-and-shrink to the container.
193
+ const isDayGrid = this.type === 'day-grid';
194
+ const cellTrack = isDayGrid ? 'var(--heatmap-cell-min-size, 0.75rem)' : 'minmax(0, 1fr)';
189
195
  const grid = document.createElement('div');
190
196
  grid.setAttribute('data-grid', '');
191
- grid.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
192
- grid.style.gridTemplateRows = `repeat(${rows}, 1fr)`;
197
+ grid.style.gridTemplateColumns = `repeat(${cols}, ${cellTrack})`;
198
+ grid.style.gridTemplateRows = `repeat(${rows}, ${isDayGrid ? 'var(--heatmap-cell-min-size, 0.75rem)' : '1fr'})`;
193
199
  for (let r = 0; r < rows; r++) {
194
200
  for (let c = 0; c < cols; c++) {
195
201
  const d = map.get(`${r},${c}`);
@@ -36,6 +36,19 @@
36
36
  gap: var(--heatmap-gap, var(--heatmap-gap-default));
37
37
  color: var(--heatmap-text, var(--heatmap-text-default));
38
38
  font-size: var(--a-body-size);
39
+ /* A wide day-grid (52+ week columns) keeps a FIXED cell size and scrolls
40
+ horizontally (the GitHub-contributions model) rather than crushing
41
+ fixed-size cells into overlap. matrix/density fit-and-shrink instead. */
42
+ overflow-x: auto;
43
+ scrollbar-width: thin;
44
+ }
45
+
46
+ /* day-grid: fixed-size cell tracks + intrinsic width so the host scrolls
47
+ instead of squishing. (class.js writes the fixed gridTemplateColumns
48
+ inline for day-grid; matrix/density keep minmax(0,1fr) to fit.) */
49
+ :scope[type="day-grid"] > [data-grid],
50
+ :scope[type="day-grid"] > [data-months] {
51
+ width: max-content;
39
52
  }
40
53
 
41
54
  /* Title slot */
@@ -73,8 +86,12 @@
73
86
  :scope [data-cell] {
74
87
  border-radius: var(--heatmap-cell-radius, var(--heatmap-cell-radius-default));
75
88
  background: var(--heatmap-empty-bg, var(--heatmap-empty-bg-default));
76
- min-width: var(--heatmap-cell-min-size, var(--heatmap-cell-min-size-default));
77
- min-height: var(--heatmap-cell-min-size, var(--heatmap-cell-min-size-default));
89
+ /* No hard min on the cell box — it conflicted with matrix/density's
90
+ minmax(0,1fr) tracks (the floor refused to shrink, so cells overlapped
91
+ into a smear). day-grid keeps fixed size via its fixed tracks; the
92
+ fit-to-container modes shrink with the track. */
93
+ min-width: 0;
94
+ min-height: 0;
78
95
  aspect-ratio: 1 / 1;
79
96
  cursor: default;
80
97
  transition: transform var(--a-duration-fast) var(--a-easing-out);
@@ -14,13 +14,16 @@
14
14
  :scope {
15
15
  /* ── Base ── */
16
16
  box-sizing: border-box;
17
- display: inline-block;
17
+ display: block;
18
18
  position: relative;
19
19
  overflow: hidden;
20
20
  background: var(--image-bg, var(--image-bg-default));
21
21
  border-radius: var(--image-radius, var(--image-radius-default));
22
22
  }
23
23
 
24
+ /* Display convention (ADR-0037): block-level by default; [inline] opts back to inline-level. */
25
+ :scope[inline] { display: inline-block; }
26
+
24
27
  /* ── Image ── */
25
28
  [slot="image"] {
26
29
  display: block;
@@ -229,7 +229,7 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
229
229
  past the half-column cell). Targeting icon-ui directly is required
230
230
  because its own `:where(:scope)` declaration of --icon-size wins over
231
231
  any value inherited from its parent button-ui. Tying it to
232
- --input-height keeps the chevron proportional across sm/md/lg. */
232
+ --input-height keeps the caret proportional across sm/md/lg. */
233
233
  [data-number] [slot="controls"] icon-ui {
234
234
  --icon-size: calc(var(--input-height, var(--input-height-default)) * 0.4);
235
235
  }
@@ -55,6 +55,25 @@ const BADGE_FOR_STATUS = Object.freeze({
55
55
  'coming-soon':{ variant: 'muted', text: 'Coming soon', icon: '' },
56
56
  });
57
57
 
58
+ // Fallback glyph per known provider when no explicit [logo] is supplied, so
59
+ // a card set up with just [provider] never renders a blank logo disc. All
60
+ // names are verified Phosphor icons. Unknown providers fall back to a generic
61
+ // plug glyph.
62
+ const PROVIDER_LOGO = Object.freeze({
63
+ slack: 'chat-circle',
64
+ github: 'git-branch',
65
+ gitlab: 'git-branch',
66
+ linear: 'kanban',
67
+ stripe: 'credit-card',
68
+ zapier: 'lightning',
69
+ figma: 'figma-logo',
70
+ notion: 'notion-logo',
71
+ webhook: 'lightning',
72
+ discord: 'discord-logo',
73
+ google: 'google-logo',
74
+ });
75
+ const DEFAULT_LOGO = 'plug';
76
+
58
77
  export class UIIntegrationCard extends UIElement {
59
78
  // Phosphor icons this primitive auto-stamps (without consumer markup).
60
79
  // Aggregated by installIconLoadersForRegistered() across all defined
@@ -238,8 +257,13 @@ export class UIIntegrationCard extends UIElement {
238
257
  // a "not found" warn for every {{p:4}} it receives on first connect.
239
258
  if (logo.startsWith('{{p:')) return;
240
259
 
241
- // No logo strip any prior content and hide.
242
- if (!logo) {
260
+ // Resolve the visual: an explicit [logo] wins; otherwise fall back to a
261
+ // per-provider glyph (or a generic plug) so a card with only [provider]
262
+ // never renders a blank disc. Hide only when there's neither.
263
+ const provider = (this.provider || '').trim().toLowerCase();
264
+ const resolved = logo || PROVIDER_LOGO[provider] || (provider ? DEFAULT_LOGO : '');
265
+
266
+ if (!resolved) {
243
267
  this.#logoEl.replaceChildren();
244
268
  this.#logoEl.hidden = true;
245
269
  return;
@@ -247,18 +271,18 @@ export class UIIntegrationCard extends UIElement {
247
271
  this.#logoEl.hidden = false;
248
272
 
249
273
  // URL vs icon-name sniff: presence of '/' → URL.
250
- const isUrl = logo.includes('/');
274
+ const isUrl = resolved.includes('/');
251
275
 
252
276
  if (isUrl) {
253
277
  // Reuse existing <img> if same src; otherwise re-stamp.
254
278
  let img = this.#logoEl.querySelector(':scope > img');
255
- if (img && img.getAttribute('src') === logo) {
279
+ if (img && img.getAttribute('src') === resolved) {
256
280
  img.setAttribute('alt', `${this.name || this.provider || 'Integration'} logo`);
257
281
  return;
258
282
  }
259
283
  this.#logoEl.replaceChildren();
260
284
  img = document.createElement('img');
261
- img.setAttribute('src', logo);
285
+ img.setAttribute('src', resolved);
262
286
  img.setAttribute('alt', `${this.name || this.provider || 'Integration'} logo`);
263
287
  img.setAttribute('data-integration-logo', '');
264
288
  img.setAttribute('loading', 'lazy');
@@ -269,10 +293,10 @@ export class UIIntegrationCard extends UIElement {
269
293
 
270
294
  // Icon name → <icon-ui>.
271
295
  let icon = this.#logoEl.querySelector(':scope > icon-ui');
272
- if (icon && icon.getAttribute('name') === logo) return;
296
+ if (icon && icon.getAttribute('name') === resolved) return;
273
297
  this.#logoEl.replaceChildren();
274
298
  icon = document.createElement('icon-ui');
275
- icon.setAttribute('name', logo);
299
+ icon.setAttribute('name', resolved);
276
300
  icon.setAttribute('aria-hidden', 'true');
277
301
  this.#logoEl.appendChild(icon);
278
302
  }
@@ -256,11 +256,22 @@ describe('integration-card-ui — logo rendering', () => {
256
256
  expect(icon.getAttribute('name')).toBe('lightning');
257
257
  });
258
258
 
259
- it('hides logo wrapper when logo prop is empty', async () => {
259
+ it('renders a provider fallback glyph when logo prop is empty', async () => {
260
+ // Empty [logo] + a [provider] → the per-provider Phosphor glyph fallback
261
+ // (instead of a blank logo disc): wrapper stays visible with an <icon-ui>.
260
262
  const c = mount('<integration-card-ui provider="slack" name="Slack"></integration-card-ui>');
261
263
  await tick2();
262
264
  const wrap = c.querySelector('[data-integration-card-logo]');
263
265
  expect(wrap).not.toBeNull();
266
+ expect(wrap.hidden).toBe(false);
267
+ expect(wrap.querySelector('icon-ui')).not.toBeNull();
268
+ });
269
+
270
+ it('hides logo wrapper when both logo and provider are empty', async () => {
271
+ const c = mount('<integration-card-ui name="Custom"></integration-card-ui>');
272
+ await tick2();
273
+ const wrap = c.querySelector('[data-integration-card-logo]');
274
+ expect(wrap).not.toBeNull();
264
275
  expect(wrap.hidden).toBe(true);
265
276
  });
266
277
  });
@@ -17,11 +17,12 @@
17
17
  "const": "Kbd"
18
18
  },
19
19
  "size": {
20
- "description": "Sizing scale (compact tier — sm / md only).",
20
+ "description": "Sizing scale: sm, md (default), lg.",
21
21
  "type": "string",
22
22
  "enum": [
23
23
  "sm",
24
- "md"
24
+ "md",
25
+ "lg"
25
26
  ],
26
27
  "default": ""
27
28
  }
@@ -7,8 +7,10 @@
7
7
  --kbd-radius-default: var(--a-radius-sm);
8
8
  --kbd-font-default: var(--a-font-family-code);
9
9
 
10
- /* Size — defaults to md */
11
- --kbd-font-size-default: var(--a-ui-tiny);
10
+ /* Size — defaults to md. Glyph is --a-ui-xs (12px) so md reads distinctly
11
+ larger than sm (--a-ui-tiny 10px) inside its 20px cap — previously both
12
+ md and sm resolved to --a-ui-tiny, so the glyphs were identical. */
13
+ --kbd-font-size-default: var(--a-ui-xs);
12
14
  --kbd-height-default: 1.25rem;
13
15
  --kbd-min-width-default: 1.25rem;
14
16
  --kbd-px-default: var(--a-space-1);
@@ -19,8 +21,9 @@
19
21
  --kbd-height-sm-default: 1rem;
20
22
  --kbd-min-width-sm-default: 1rem;
21
23
 
22
- /* Size: lg */
23
- --kbd-font-size-lg-default: var(--a-ui-sm);
24
+ /* Size: lg — glyph --a-ui-md (14px), the top of the three-tier ramp
25
+ (sm 10 / md 12 / lg 14). */
26
+ --kbd-font-size-lg-default: var(--a-ui-md);
24
27
  --kbd-height-lg-default: 1.5rem;
25
28
  --kbd-min-width-lg-default: 1.5rem;
26
29
  text-align: start; /* §text-align-reset — blocks inheritance from centered ancestors */
@@ -13,6 +13,6 @@
13
13
  import { UIElement } from '../../core/element.js';
14
14
 
15
15
  export class UIKbd extends UIElement {
16
- /** Sizing scale (compact tier — sm / md only). */
17
- size: 'sm' | 'md';
16
+ /** Sizing scale: sm, md (default), lg. */
17
+ size: 'sm' | 'md' | 'lg';
18
18
  }
@@ -11,12 +11,13 @@ description: >-
11
11
  menu items, tooltips, command hints, and shortcut documentation.
12
12
  props:
13
13
  size:
14
- description: Sizing scale (compact tier — sm / md only).
14
+ description: 'Sizing scale: sm, md (default), lg.'
15
15
  type: string
16
16
  default: ''
17
17
  enum:
18
18
  - sm
19
19
  - md
20
+ - lg
20
21
  reflect: true
21
22
  events: {}
22
23
  slots:
@@ -87,8 +87,15 @@ export class UIList extends UIElement {
87
87
  }
88
88
 
89
89
  #items() {
90
+ // Match by TAG as well as role: a child <list-item-ui> sets role="listitem"
91
+ // in its OWN connected(), which (light-DOM upgrade order) has NOT run yet
92
+ // when the parent list first render()s. A role-only filter therefore found
93
+ // zero items on the initial selectable render → aria-selected was never
94
+ // stamped → the [aria-selected="true"] selection CSS never matched. The tag
95
+ // name is present pre-upgrade, so this finds the rows regardless of timing;
96
+ // the attribute (aria-selected) we set persists through their later upgrade.
90
97
  return [...this.children].filter(
91
- (el) => el.getAttribute && el.getAttribute('role') === 'listitem',
98
+ (el) => el.tagName === 'LIST-ITEM-UI' || el.getAttribute?.('role') === 'listitem',
92
99
  );
93
100
  }
94
101
 
@@ -12,10 +12,13 @@
12
12
 
13
13
  :scope {
14
14
  box-sizing: border-box;
15
- display: inline-flex;
15
+ display: flex;
16
16
  position: relative;
17
17
  }
18
18
 
19
+ /* Display convention (ADR-0037): block-level by default; [inline] opts back to inline-level. */
20
+ :scope[inline] { display: inline-flex; }
21
+
19
22
  /* Items/dividers in Light DOM are hidden unless they've been adopted
20
23
  into the popover on open. Popover API also hides the popover itself
21
24
  when closed. */
@@ -122,7 +122,16 @@ export class UIModal extends UIElement {
122
122
  }
123
123
 
124
124
  #getDuration() {
125
- const raw = getComputedStyle(this).getPropertyValue('--modal-duration').trim();
125
+ // Read the same fallback chain the CSS transitions use —
126
+ // var(--modal-duration, var(--modal-duration-default)). Reading only the
127
+ // public token (unset unless a consumer overrides) made this always fall
128
+ // back to 200ms while the CSS exit animation ran for --modal-duration-default
129
+ // (= --a-duration = 250ms), so the dialog closed 50ms early, clipping the
130
+ // animation. Same class as swiper bug-29 (the OD-5 -default token sweep left
131
+ // JS read-sites pointed at the now-unset public token).
132
+ const cs = getComputedStyle(this);
133
+ const raw = cs.getPropertyValue('--modal-duration').trim()
134
+ || cs.getPropertyValue('--modal-duration-default').trim();
126
135
  return parseFloat(raw) || 200;
127
136
  }
128
137
 
@@ -142,6 +142,12 @@
142
142
  flex: 1;
143
143
  overflow: auto;
144
144
  }
145
+ /* Form controls in the modal body fill the available width (a modal body is
146
+ a form column, not a content-width row) — matches the field/input family's
147
+ full-width behaviour in a real form. */
148
+ :scope [slot="body"] :is(field-ui, input-ui, select-ui, textarea-ui, combobox-ui, tags-input-ui, color-input-ui, slider-ui) {
149
+ width: 100%;
150
+ }
145
151
 
146
152
  /* ═══════ Footer ═══════ */
147
153
  :scope [slot="footer"] {
@@ -153,4 +159,7 @@
153
159
  border-top: 1px solid var(--modal-border, var(--modal-border-default));
154
160
  flex-shrink: 0;
155
161
  }
162
+ /* No content → no footer chrome: an empty footer slot otherwise renders a
163
+ dead band (border-top + padding) under the body. */
164
+ :scope [slot="footer"]:empty { display: none; }
156
165
  }
@@ -114,6 +114,9 @@
114
114
  "default": {
115
115
  "description": "Spillover content revealed only when the card is checked — typically a follow-up form field (e.g. a textarea on an \"Other\" option, conditional inputs that depend on the selection). Aligns with the heading/description column; hidden via `display: none` when not checked."
116
116
  },
117
+ "action": {
118
+ "description": "CTA slot for `layout=\"tile\"` — a button-ui anchored to the card's bottom edge and stretched full-width, so a row of tile cards keeps their CTAs aligned regardless of body length (plan-picker-style pickers)."
119
+ },
117
120
  "heading": {
118
121
  "description": "Rich heading content. Overrides the `heading` attribute when present."
119
122
  },
@@ -173,34 +173,59 @@ option-card-ui[checked] [slot="icon"] {
173
173
  description below, all left-aligned. Used for hero pickers
174
174
  (data source, role, plan tiles) where the icon is a primary
175
175
  brand cue rather than secondary chrome. ── */
176
+ /* Tile layout — a vertical content stack (icon → heading → description →
177
+ body → action) with the radio indicator pinned top-right OUT of flow.
178
+ Flex (not grid) so content flows in DOM order with no reserved/empty
179
+ tracks: a no-icon plan card has no dead left column, and a slotted CTA
180
+ (slot="action") anchors to the card bottom regardless of how much body
181
+ content (price / feature list) precedes it. (Plan Picker option-card grid) */
176
182
  :scope[layout="tile"] {
177
- grid-template-columns: minmax(0, 1fr) auto;
178
- grid-template-areas:
179
- "icon indicator"
180
- "heading heading"
181
- "description description";
182
- column-gap: var(--option-card-gap-x, var(--option-card-gap-x-default));
183
- row-gap: var(--option-card-gap-y, var(--option-card-gap-y-default));
184
- padding: var(--a-space-4);
185
- align-items: start;
183
+ display: flex;
184
+ flex-direction: column;
185
+ gap: var(--option-card-gap-y, var(--option-card-gap-y-default));
186
+ position: relative;
187
+ /* padding inherited from base :scope (token-driven, --option-card-padding-*) */
188
+ }
189
+ /* Radio pinned to the card's top-right corner, OUT of flow. Clear the
190
+ base grid-area:indicator so the absolute containing block is the card
191
+ itself (position:relative) rather than a collapsed grid cell — otherwise
192
+ a grid-forcing host (plan-picker-ui) strands the indicator in an 8px
193
+ gutter and inset-inline-end resolves negative. Inset by the active
194
+ padding token so the dot aligns with the content edge. */
195
+ :scope[layout="tile"]::before {
196
+ position: absolute;
197
+ grid-area: auto;
198
+ inset-block-start: var(--option-card-padding-block, var(--option-card-padding-block-default));
199
+ inset-inline-end: var(--option-card-padding-inline, var(--option-card-padding-inline-default));
200
+ margin-block-start: 0;
186
201
  }
202
+ /* Visual order is fixed via `order`, NOT DOM order: composites
203
+ (plan-picker-ui) append default-slot body content BEFORE option-card
204
+ stamps the heading/description spans on connect, so a flex stack that
205
+ trusted DOM order would render the body above the title. */
187
206
  :scope[layout="tile"] [slot="icon"] {
188
- grid-area: icon;
189
- justify-self: start;
207
+ order: 1;
190
208
  align-self: start;
191
209
  --option-card-icon-size-default: 1.75rem;
192
210
  }
193
- :scope[layout="tile"]::before {
194
- grid-area: indicator;
195
- align-self: start;
196
- justify-self: end;
197
- margin-block-start: 0;
198
- }
199
211
  :scope[layout="tile"] [slot="heading"] {
200
- margin-block-start: var(--a-space-2);
212
+ order: 2;
213
+ /* Reserve the top-right radio's footprint so a long heading doesn't run
214
+ under it. */
215
+ padding-inline-end: var(--a-space-6);
216
+ }
217
+ :scope[layout="tile"] [slot="description"] {
218
+ order: 3;
201
219
  }
202
220
  :scope[layout="tile"] > :not([slot]) {
203
- grid-column: 1 / -1;
221
+ order: 4;
222
+ }
223
+ /* CTA anchors to the card bottom (auto top-margin) + full-width, so a row of
224
+ plan cards with different body lengths keeps their buttons aligned. */
225
+ :scope[layout="tile"] [slot="action"] {
226
+ order: 5;
227
+ width: 100%;
228
+ margin-block-start: auto;
204
229
  }
205
230
 
206
231
  /* ── State: disabled ── */
@@ -76,6 +76,11 @@ slots:
76
76
  description: Rich description content. Overrides the `description` attribute when present.
77
77
  icon:
78
78
  description: Custom icon element. Overrides the `icon` attribute when present.
79
+ action:
80
+ description: >-
81
+ CTA slot for `layout="tile"` — a button-ui anchored to the card's bottom
82
+ edge and stretched full-width, so a row of tile cards keeps their CTAs
83
+ aligned regardless of body length (plan-picker-style pickers).
79
84
  default:
80
85
  description: >-
81
86
  Spillover content revealed only when the card is checked — typically a
@@ -1,9 +1,13 @@
1
1
  @scope (otp-input-ui) {
2
2
  :where(:scope) {
3
3
  /* ── Tokens ── */
4
- --otp-input-size-default: var(--a-size);
4
+ /* Digit boxes grow to fill the row, capped at ~1.75× the base control size
5
+ (~52px) so they read as comfortable single-digit boxes. Radius is the
6
+ plain --a-radius-lg token (no min()/clamp expression — radius patterns
7
+ stay simple). Scales with [size] + density (relative to --a-size). */
8
+ --otp-input-size-default: calc(var(--a-size) * 1.75);
5
9
  --otp-input-gap-default: var(--a-space-2);
6
- --otp-input-radius-default: var(--a-radius-md);
10
+ --otp-input-radius-default: var(--a-radius-lg);
7
11
  --otp-input-border-default: var(--a-ui-border);
8
12
  --otp-input-border-hover-default: var(--a-ui-border-hover);
9
13
  --otp-input-border-focus-default: var(--a-accent);
@@ -24,15 +28,27 @@
24
28
  /* ── Base ── */
25
29
  box-sizing: border-box;
26
30
  display: flex;
27
- justify-content: space-around;
31
+ justify-content: center;
28
32
  gap: var(--otp-input-gap, var(--otp-input-gap-default));
33
+ /* Fill the container (a block-level control per ADR-0037) so the digit
34
+ boxes grow to use the available width instead of sitting content-width
35
+ on the left. Boxes flex to fill up to their cap; once capped (very wide
36
+ container) the group centers rather than packing left. (bug-37 follow-up) */
37
+ width: 100%;
29
38
  }
30
39
 
31
40
  /* ── Digit inputs ── */
41
+ /* Boxes grow equally to fill the row (flex), staying square (aspect-ratio)
42
+ and capped at --otp-input-size so they read as digit boxes — not huge on a
43
+ wide container, not cramped on a narrow one. The cap keeps --a-radius-md
44
+ proportional (a rounded square, ~25-30%), which is the whole point of the
45
+ bug-37 sizing. */
32
46
  [slot="digit"] {
33
47
  box-sizing: border-box;
34
- width: var(--otp-input-size, var(--otp-input-size-default));
35
- height: var(--otp-input-size, var(--otp-input-size-default));
48
+ flex: 1 1 0;
49
+ min-width: 0;
50
+ max-width: var(--otp-input-size, var(--otp-input-size-default));
51
+ aspect-ratio: 1;
36
52
  text-align: center;
37
53
  border: 1px solid var(--otp-input-border, var(--otp-input-border-default));
38
54
  border-radius: var(--otp-input-radius, var(--otp-input-radius-default));
@@ -67,12 +83,11 @@
67
83
  :scope[disabled] [slot="digit"] {
68
84
  background: var(--otp-input-bg-disabled, var(--otp-input-bg-disabled-default));
69
85
  color: var(--otp-input-fg-disabled, var(--otp-input-fg-disabled-default));
70
- /* Dashed border rendered via SVG border-image for consistent ~8px
71
- dashes across browsers (native `border-style: dashed` is too short
72
- and varies by engine). The border-color fallback is used when
73
- border-image isn't supported. */
86
+ /* Native dashed border it follows the box's border-radius (a rounded
87
+ dashed square, matching the enabled state). An SVG border-image would
88
+ give more uniform dashes but is clipped to a SQUARE — it ignores
89
+ border-radius — so the disabled corners wouldn't round. */
74
90
  border: 1px dashed var(--otp-input-border-disabled, var(--otp-input-border-disabled-default));
75
- border-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' preserveAspectRatio='none'><rect x='0.5' y='0.5' width='27' height='27' fill='none' stroke='%23999' stroke-width='1' stroke-dasharray='8 6'/></svg>") 1 repeat;
76
91
  cursor: not-allowed;
77
92
  }
78
93
  }