@adia-ai/web-modules 0.8.35 → 0.8.37

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 (89) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/agent-admin/admin-roster/admin-roster.js +1 -1
  4. package/agent-admin/admin-settings/admin-settings.js +1 -1
  5. package/agent-admin/agent-admin/agent-admin.js +1 -1
  6. package/billing/billing-overview/billing-overview.a2ui.json +281 -0
  7. package/billing/billing-overview/billing-overview.class.js +838 -0
  8. package/billing/billing-overview/billing-overview.css +223 -0
  9. package/billing/billing-overview/billing-overview.js +16 -0
  10. package/billing/billing-overview/billing-overview.yaml +441 -0
  11. package/billing/index.js +21 -0
  12. package/billing/invoice-detail/invoice-detail.a2ui.json +276 -0
  13. package/billing/invoice-detail/invoice-detail.class.js +711 -0
  14. package/billing/invoice-detail/invoice-detail.css +305 -0
  15. package/billing/invoice-detail/invoice-detail.js +16 -0
  16. package/billing/invoice-detail/invoice-detail.yaml +411 -0
  17. package/billing/invoice-history/invoice-history.a2ui.json +315 -0
  18. package/billing/invoice-history/invoice-history.class.js +672 -0
  19. package/billing/invoice-history/invoice-history.css +130 -0
  20. package/billing/invoice-history/invoice-history.js +16 -0
  21. package/billing/invoice-history/invoice-history.yaml +398 -0
  22. package/billing/payment-method-form/payment-method-form.a2ui.json +310 -0
  23. package/billing/payment-method-form/payment-method-form.class.js +886 -0
  24. package/billing/payment-method-form/payment-method-form.css +149 -0
  25. package/billing/payment-method-form/payment-method-form.js +16 -0
  26. package/billing/payment-method-form/payment-method-form.yaml +430 -0
  27. package/billing/payment-method-list/payment-method-list.a2ui.json +283 -0
  28. package/billing/payment-method-list/payment-method-list.class.js +705 -0
  29. package/billing/payment-method-list/payment-method-list.css +128 -0
  30. package/billing/payment-method-list/payment-method-list.js +16 -0
  31. package/billing/payment-method-list/payment-method-list.yaml +355 -0
  32. package/billing/plan-picker/plan-picker.a2ui.json +349 -0
  33. package/billing/plan-picker/plan-picker.class.js +635 -0
  34. package/billing/plan-picker/plan-picker.css +227 -0
  35. package/billing/plan-picker/plan-picker.js +16 -0
  36. package/billing/plan-picker/plan-picker.yaml +453 -0
  37. package/chat/README.md +1 -1
  38. package/chat/chat-composer/chat-composer.js +2 -2
  39. package/chat/chat-empty/chat-empty.js +2 -2
  40. package/chat/chat-shell/chat-shell.js +4 -4
  41. package/chat/chat-sidebar/chat-sidebar.js +2 -2
  42. package/chat/chat-surfaces/chat-surfaces.js +2 -2
  43. package/chat/chat-thread/chat-thread.js +2 -2
  44. package/dashboard/dashboard-layout/dashboard-layout.a2ui.json +254 -0
  45. package/dashboard/dashboard-layout/dashboard-layout.css +190 -0
  46. package/dashboard/dashboard-layout/dashboard-layout.js +289 -0
  47. package/dashboard/dashboard-layout/dashboard-layout.yaml +374 -0
  48. package/dashboard/date-range-selector/date-range-selector.a2ui.json +236 -0
  49. package/dashboard/date-range-selector/date-range-selector.css +84 -0
  50. package/dashboard/date-range-selector/date-range-selector.js +686 -0
  51. package/dashboard/date-range-selector/date-range-selector.yaml +358 -0
  52. package/dashboard/index.js +14 -0
  53. package/dist/everything.min.js +1 -1
  54. package/editor/editor-canvas/editor-canvas.js +2 -2
  55. package/editor/editor-shell/editor-shell.js +2 -2
  56. package/editor/editor-sidebar/editor-sidebar.js +2 -2
  57. package/editor/editor-toolbar/editor-toolbar.js +2 -2
  58. package/feedback/confirm-dialog/confirm-dialog.a2ui.json +206 -0
  59. package/feedback/confirm-dialog/confirm-dialog.css +157 -0
  60. package/feedback/confirm-dialog/confirm-dialog.js +383 -0
  61. package/feedback/confirm-dialog/confirm-dialog.yaml +297 -0
  62. package/feedback/index.js +10 -0
  63. package/form/form-popover/form-popover.js +1 -1
  64. package/generative/index.d.ts +1 -1
  65. package/generative/index.js +2 -2
  66. package/onboarding/index.js +1 -0
  67. package/onboarding/onboarding-checklist/onboarding-checklist.a2ui.json +263 -0
  68. package/onboarding/onboarding-checklist/onboarding-checklist.css +157 -0
  69. package/onboarding/onboarding-checklist/onboarding-checklist.js +428 -0
  70. package/onboarding/onboarding-checklist/onboarding-checklist.yaml +236 -0
  71. package/package.json +99 -6
  72. package/runtime/a2ui-root/a2ui-root.js +3 -3
  73. package/runtime/gen-root/gen-root.js +5 -5
  74. package/settings/index.js +12 -0
  75. package/settings/integrations-page/integrations-page.a2ui.json +274 -0
  76. package/settings/integrations-page/integrations-page.css +177 -0
  77. package/settings/integrations-page/integrations-page.d.ts +77 -0
  78. package/settings/integrations-page/integrations-page.js +413 -0
  79. package/settings/integrations-page/integrations-page.yaml +317 -0
  80. package/settings/notification-preferences/notification-preferences.a2ui.json +245 -0
  81. package/settings/notification-preferences/notification-preferences.css +204 -0
  82. package/settings/notification-preferences/notification-preferences.js +622 -0
  83. package/settings/notification-preferences/notification-preferences.yaml +358 -0
  84. package/shell/admin-command/admin-command.js +2 -2
  85. package/shell/admin-shell/admin-shell.js +2 -2
  86. package/shell/admin-sidebar/admin-sidebar.js +2 -2
  87. package/shell/embed-shell/embed-shell.js +3 -3
  88. package/simple/simple-shell/simple-shell.js +1 -1
  89. package/theme/theme-panel/theme-panel.js +2 -2
@@ -0,0 +1,886 @@
1
+ /**
2
+ * <payment-method-form-ui> — Form-participating payment-method composite.
3
+ *
4
+ * Captures card number, expiry, CVC, name on card, billing address, and
5
+ * country through a grid of <field-ui> + <input-ui> + <select-ui>
6
+ * sub-controls. Per ADR-0025 the host IS the form-associated element —
7
+ * no native <input type=text> is wrapped at the composite tier; the
8
+ * leaf primitives carry their own ElementInternals.
9
+ *
10
+ * Card-number Luhn validation, MM/YY expiry parsing, CVC 3-4 digit
11
+ * length check, brand detection via small regex map (visa /
12
+ * mastercard / amex / discover / unknown). The form chrome + structured
13
+ * validation lives here; tokenization against a real processor is the
14
+ * consumer's responsibility (Stripe Elements, Braintree, Adyen).
15
+ *
16
+ * Form value is a synthetic opaque local-mode token written ONLY after
17
+ * tokenize() resolves; raw card digits never leak through `value`.
18
+ *
19
+ * SPEC: .claude/docs/specs/implementation-ready/SPEC-009-payment-method-form.md
20
+ * ADR: .brain/adrs/0025-no-native-form-controls.md
21
+ * ADR: .brain/adrs/0033-light-dom-substrate.md
22
+ *
23
+ * Architecture (light-DOM, ADR-0033):
24
+ * Stamps a fixed skeleton once on first connect:
25
+ * [data-alert] · [data-summary] · [data-grid > field rows] · [data-legal]
26
+ * Mutates per-field state via reflected attributes on each sub-control.
27
+ * No template rebuilds across renders — field nodes keep identity so
28
+ * focus + IME composition state survive.
29
+ *
30
+ * Public API:
31
+ * .tokenize() — Promise resolving the normalized payload (or rejects).
32
+ * .reset() — Clear every field + the resolved token.
33
+ * .checkValidity() / .reportValidity() — inherited from UIFormElement.
34
+ */
35
+
36
+ import { UIFormElement } from '@adia-ai/web-components/core/form';
37
+ import { untracked } from '@adia-ai/web-components/core/signals';
38
+
39
+ // ── Brand detection ────────────────────────────────────────────────
40
+ //
41
+ // Order matters: Amex regex tested first (its prefix overlaps with
42
+ // Mastercard's older range). Conservative regex — production
43
+ // detection uses BIN tables, but a small map covers the brand
44
+ // recognition the LLM corpus needs (visa / mastercard / amex /
45
+ // discover).
46
+ const BRAND_PATTERNS = [
47
+ { brand: 'amex', re: /^3[47]/ },
48
+ { brand: 'visa', re: /^4/ },
49
+ { brand: 'mastercard', re: /^(5[1-5]|2[2-7])/ },
50
+ { brand: 'discover', re: /^6(011|5)/ },
51
+ ];
52
+
53
+ /** Detect the card brand from a normalized digits-only PAN. */
54
+ function detectBrand(digits) {
55
+ if (!digits) return 'unknown';
56
+ for (const { brand, re } of BRAND_PATTERNS) {
57
+ if (re.test(digits)) return brand;
58
+ }
59
+ return 'unknown';
60
+ }
61
+
62
+ /** Strip all non-digit characters; keep at most 19 digits (Maestro). */
63
+ function digitsOnly(s) {
64
+ return String(s || '').replace(/\D+/g, '').slice(0, 19);
65
+ }
66
+
67
+ /** Format a PAN string with spaces every 4 digits (Amex uses 4-6-5). */
68
+ function formatPan(digits, brand) {
69
+ if (!digits) return '';
70
+ if (brand === 'amex') {
71
+ return digits.replace(/^(\d{0,4})(\d{0,6})(\d{0,5}).*$/, (_, a, b, c) =>
72
+ [a, b, c].filter(Boolean).join(' '),
73
+ );
74
+ }
75
+ return digits.match(/.{1,4}/g)?.join(' ') ?? '';
76
+ }
77
+
78
+ /** Luhn check on a digits-only string. */
79
+ function luhn(digits) {
80
+ if (!digits || digits.length < 12) return false;
81
+ let sum = 0;
82
+ let alt = false;
83
+ for (let i = digits.length - 1; i >= 0; i--) {
84
+ let n = digits.charCodeAt(i) - 48;
85
+ if (n < 0 || n > 9) return false;
86
+ if (alt) {
87
+ n *= 2;
88
+ if (n > 9) n -= 9;
89
+ }
90
+ sum += n;
91
+ alt = !alt;
92
+ }
93
+ return sum % 10 === 0;
94
+ }
95
+
96
+ /**
97
+ * Parse an MM/YY (or MM/YYYY) string into { month, year } where year
98
+ * is the 4-digit form. Returns null on parse failure. Accepts "12/27",
99
+ * "12 / 27", "12/2027". Does NOT bounds-check month/year sanity beyond
100
+ * the parse — that's the validation step's job.
101
+ */
102
+ function parseExpiry(s) {
103
+ if (!s) return null;
104
+ const m = String(s).replace(/\s+/g, '').match(/^(\d{1,2})\/(\d{2}|\d{4})$/);
105
+ if (!m) return null;
106
+ const month = Number(m[1]);
107
+ let year = Number(m[2]);
108
+ if (year < 100) year += 2000;
109
+ if (!Number.isFinite(month) || !Number.isFinite(year)) return null;
110
+ return { month, year };
111
+ }
112
+
113
+ /** Validate an expiry. Returns null on OK, an error code otherwise. */
114
+ function validateExpiry(s) {
115
+ const parsed = parseExpiry(s);
116
+ if (!parsed) return 'expiry-format';
117
+ const { month, year } = parsed;
118
+ if (month < 1 || month > 12) return 'expiry-month';
119
+ const now = new Date();
120
+ const nowYear = now.getFullYear();
121
+ const nowMonth = now.getMonth() + 1;
122
+ if (year < nowYear || (year === nowYear && month < nowMonth)) {
123
+ return 'expiry-past';
124
+ }
125
+ if (year > nowYear + 50) return 'expiry-far';
126
+ return null;
127
+ }
128
+
129
+ /** CVC validation — 3 digits for most brands, 4 for Amex. */
130
+ function validateCvc(cvc, brand) {
131
+ const digits = String(cvc || '').replace(/\D+/g, '');
132
+ const wantLen = brand === 'amex' ? 4 : 3;
133
+ if (digits.length !== wantLen) return 'cvc-length';
134
+ return null;
135
+ }
136
+
137
+ /** Generate a synthetic opaque local-mode token. NOT a real PSP token. */
138
+ function makeLocalToken(seed) {
139
+ // 32-char hex-ish opaque; sourced from Math.random + timestamp. Real
140
+ // processors mint real tokens; this is the local-mode placeholder so
141
+ // consumers can wire the event without a network round-trip.
142
+ const rnd = Math.random().toString(36).slice(2);
143
+ const ts = Date.now().toString(36);
144
+ return `pm_local_${ts}_${rnd}_${String(seed).slice(0, 4)}`;
145
+ }
146
+
147
+ const DEFAULT_COUNTRIES = ['US', 'CA', 'GB', 'DE', 'FR', 'AU'];
148
+
149
+ /** Map alpha-2 to a human-readable country name. Small built-in set. */
150
+ const COUNTRY_NAMES = {
151
+ US: 'United States',
152
+ CA: 'Canada',
153
+ GB: 'United Kingdom',
154
+ DE: 'Germany',
155
+ FR: 'France',
156
+ AU: 'Australia',
157
+ JP: 'Japan',
158
+ ES: 'Spain',
159
+ IT: 'Italy',
160
+ NL: 'Netherlands',
161
+ SE: 'Sweden',
162
+ IE: 'Ireland',
163
+ SG: 'Singapore',
164
+ IN: 'India',
165
+ BR: 'Brazil',
166
+ MX: 'Mexico',
167
+ };
168
+
169
+ function countryName(code) {
170
+ return COUNTRY_NAMES[code] || code;
171
+ }
172
+
173
+ export class UIPaymentMethodForm extends UIFormElement {
174
+ // The host carries aria-label directly; per-control `label` deprecation
175
+ // doesn't apply at this tier (we don't expose a `label` prop).
176
+ static labelDeprecated = false;
177
+
178
+ static get properties() {
179
+ return {
180
+ ...UIFormElement.properties,
181
+ name: { type: String, default: 'paymentMethod', reflect: true },
182
+ value: { type: String, default: '', reflect: true },
183
+ prefill: { type: String, default: '', attribute: 'prefill' },
184
+ countries: { type: Array, default: [] },
185
+ defaultCountry: { type: String, default: 'US', reflect: true, attribute: 'default-country' },
186
+ requireBillingAddress: { type: Boolean, default: false, reflect: true, attribute: 'require-billing-address' },
187
+ disabled: { type: Boolean, default: false, reflect: true },
188
+ required: { type: Boolean, default: false, reflect: true },
189
+ error: { type: String, default: '', reflect: true },
190
+ autofocus: { type: Boolean, default: false, reflect: true },
191
+ // gh#853: tokenize() below already reflects this via raw
192
+ // setAttribute/removeAttribute; declared here too so `el.tokenizing`
193
+ // (JS property reads/writes) stays in sync per audit:static-properties-vs-yaml
194
+ // (yaml `reflect: true` requires a matching static-properties entry).
195
+ tokenizing: { type: Boolean, default: false, reflect: true },
196
+ };
197
+ }
198
+
199
+ // Stamp-nothing per plan-picker pattern — we mutate a fixed
200
+ // skeleton rather than re-running the template engine.
201
+ static template = () => null;
202
+
203
+ // ── Field refs ────────────────────────────────────────────────────
204
+ #stamped = false;
205
+ #cleanups = [];
206
+
207
+ #alertHost = null; // <alert-ui data-alert>
208
+ #summaryHost = null; // <div data-summary>
209
+ #editButton = null; // <button-ui> inside summary
210
+ #gridHost = null; // <div data-grid>
211
+
212
+ // Per-row <field-ui> hosts (keyed by field name).
213
+ #fields = {
214
+ name: null,
215
+ cardNumber: null,
216
+ expiry: null,
217
+ cvc: null,
218
+ country: null,
219
+ postalCode: null,
220
+ addressLine1: null,
221
+ addressLine2: null,
222
+ };
223
+
224
+ // Per-row <input-ui> / <select-ui> controls (keyed by field name).
225
+ #controls = {
226
+ name: null,
227
+ cardNumber: null,
228
+ expiry: null,
229
+ cvc: null,
230
+ country: null,
231
+ postalCode: null,
232
+ addressLine1: null,
233
+ addressLine2: null,
234
+ };
235
+
236
+ #brand = 'unknown';
237
+ #internalUpdate = false;
238
+ #legalHost = null;
239
+ #legalSlotProbe = null;
240
+ #tokenizing = false;
241
+
242
+ // ── Public imperative API ─────────────────────────────────────────
243
+
244
+ /**
245
+ * Validate every field + emit a resolved token. Resolves with the
246
+ * normalized payload on success; rejects with an Error carrying
247
+ * `code` + `field` on failure.
248
+ */
249
+ async tokenize() {
250
+ if (this.#tokenizing) return Promise.reject(new Error('tokenize-inflight'));
251
+ this.#tokenizing = true;
252
+ this.setAttribute('tokenizing', '');
253
+ try {
254
+ const result = this.#validateAll();
255
+ if (!result.ok) {
256
+ const err = new Error(result.message);
257
+ err.code = result.code;
258
+ err.field = result.field;
259
+ this.dispatchEvent(new CustomEvent('payment-method-error', {
260
+ bubbles: true,
261
+ detail: { message: result.message, code: result.code, field: result.field },
262
+ }));
263
+ // Set host-level error attr so the alert renders.
264
+ this.setAttribute('aria-invalid', 'true');
265
+ throw err;
266
+ }
267
+
268
+ const detail = result.detail;
269
+ // Resolve `value` to a synthetic local-mode token AFTER validation
270
+ // succeeds — raw digits never leak through.
271
+ this.#internalUpdate = true;
272
+ try {
273
+ this.value = makeLocalToken(detail.last4);
274
+ } finally {
275
+ this.#internalUpdate = false;
276
+ }
277
+ this.syncValue();
278
+ this.removeAttribute('aria-invalid');
279
+
280
+ const payload = { token: this.value, ...detail };
281
+ this.dispatchEvent(new CustomEvent('submit-payment-method', {
282
+ bubbles: true,
283
+ detail: payload,
284
+ }));
285
+ return payload;
286
+ } finally {
287
+ this.removeAttribute('tokenizing');
288
+ this.#tokenizing = false;
289
+ }
290
+ }
291
+
292
+ /** Clear every sub-field + resolved token. */
293
+ reset() {
294
+ untracked(() => {
295
+ for (const key of Object.keys(this.#controls)) {
296
+ const ctrl = this.#controls[key];
297
+ if (!ctrl) continue;
298
+ ctrl.value = '';
299
+ ctrl.removeAttribute('error');
300
+ }
301
+ if (this.#controls.country) {
302
+ this.#controls.country.value = this.defaultCountry || 'US';
303
+ }
304
+ this.#internalUpdate = true;
305
+ try {
306
+ this.value = '';
307
+ this.error = '';
308
+ } finally {
309
+ this.#internalUpdate = false;
310
+ }
311
+ this.syncValue();
312
+ this.#brand = 'unknown';
313
+ this.removeAttribute('aria-invalid');
314
+ this.removeAttribute('error');
315
+ this.#syncBrandSuffix();
316
+ });
317
+ }
318
+
319
+ // ── Form-bearing override ─────────────────────────────────────────
320
+
321
+ /** The form value is the resolved token (or empty). */
322
+ syncValue(_val) {
323
+ this.internals.setFormValue(this.value || '');
324
+ if (this.required && !this.value) {
325
+ this.internals.setValidity(
326
+ { valueMissing: true },
327
+ this.getAttribute('data-msg-required') || 'Payment method required.',
328
+ this,
329
+ );
330
+ } else {
331
+ this.internals.setValidity({});
332
+ }
333
+ }
334
+
335
+ // ── Lifecycle ─────────────────────────────────────────────────────
336
+
337
+ connected() {
338
+ super.connected();
339
+ this.setAttribute('role', 'group');
340
+ if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
341
+ this.setAttribute('aria-label', 'Payment method form');
342
+ }
343
+
344
+ if (!this.#stamped) {
345
+ this.#stamp();
346
+ this.#wireListeners();
347
+ this.#stamped = true;
348
+ }
349
+
350
+ untracked(() => this.render());
351
+
352
+ // Autofocus the first sub-field after the registry has had a
353
+ // chance to upgrade child primitives.
354
+ if (this.autofocus) {
355
+ requestAnimationFrame(() => {
356
+ const nameCtrl = this.#controls.name;
357
+ nameCtrl?.focus?.();
358
+ });
359
+ }
360
+ }
361
+
362
+ disconnected() {
363
+ super.disconnected();
364
+ for (const fn of this.#cleanups) {
365
+ try { fn(); } catch { /* ignore stale */ }
366
+ }
367
+ this.#cleanups.length = 0;
368
+ }
369
+
370
+ attributeChangedCallback(name, oldVal, newVal) {
371
+ super.attributeChangedCallback?.(name, oldVal, newVal);
372
+ if (!this.#stamped || this.#internalUpdate) return;
373
+ if (name === 'prefill' && oldVal !== newVal) {
374
+ // re-render summary
375
+ this.render();
376
+ } else if (name === 'disabled' && oldVal !== newVal) {
377
+ this.#syncDisabled();
378
+ } else if (name === 'error' && oldVal !== newVal) {
379
+ this.#syncAlert();
380
+ } else if (name === 'require-billing-address' && oldVal !== newVal) {
381
+ this.#syncBillingRows();
382
+ } else if (name === 'default-country' && oldVal !== newVal) {
383
+ const sel = this.#controls.country;
384
+ if (sel && !sel.value) sel.value = newVal || 'US';
385
+ }
386
+ }
387
+
388
+ // ── Render — narrow; mutates per-region nodes only ────────────────
389
+
390
+ render() {
391
+ if (!this.#stamped) return;
392
+
393
+ this.#syncCountries();
394
+ this.#syncBillingRows();
395
+ this.#syncDisabled();
396
+ this.#syncAlert();
397
+ this.#syncSummary();
398
+ this.#syncBrandSuffix();
399
+ }
400
+
401
+ // ── Internal — DOM stamping ───────────────────────────────────────
402
+
403
+ #stamp() {
404
+ // Host-level alert region.
405
+ const alertHost = document.createElement('alert-ui');
406
+ alertHost.setAttribute('data-alert', '');
407
+ alertHost.setAttribute('variant', 'danger');
408
+ alertHost.setAttribute('icon', 'warning-circle');
409
+ alertHost.hidden = true;
410
+ this.appendChild(alertHost);
411
+ this.#alertHost = alertHost;
412
+
413
+ // Saved-card summary region.
414
+ const summary = document.createElement('div');
415
+ summary.setAttribute('data-summary', '');
416
+ summary.hidden = true;
417
+ const cardIcon = document.createElement('icon-ui');
418
+ cardIcon.setAttribute('name', 'credit-card');
419
+ cardIcon.setAttribute('aria-hidden', 'true');
420
+ cardIcon.setAttribute('data-summary-icon', '');
421
+ summary.appendChild(cardIcon);
422
+ const summaryText = document.createElement('div');
423
+ summaryText.setAttribute('data-summary-text', '');
424
+ summary.appendChild(summaryText);
425
+ const editBtn = document.createElement('button-ui');
426
+ editBtn.setAttribute('data-summary-edit', '');
427
+ editBtn.setAttribute('variant', 'ghost');
428
+ editBtn.setAttribute('size', 'sm');
429
+ editBtn.setAttribute('text', 'Edit card');
430
+ summary.appendChild(editBtn);
431
+ this.appendChild(summary);
432
+ this.#summaryHost = summary;
433
+ this.#editButton = editBtn;
434
+
435
+ // Grid host.
436
+ const grid = document.createElement('div');
437
+ grid.setAttribute('data-grid', '');
438
+ this.appendChild(grid);
439
+ this.#gridHost = grid;
440
+
441
+ // Stamp every row up-front. Rows are hidden via `[hidden]` when
442
+ // `require-billing-address` is unset so toggling that attribute is
443
+ // a no-DOM-mutation render.
444
+
445
+ this.#fields.name = this.#stampRow('name', {
446
+ label: 'Name on card',
447
+ required: true,
448
+ area: 'name',
449
+ input: {
450
+ autocomplete: 'cc-name',
451
+ placeholder: 'Pat Doe',
452
+ },
453
+ });
454
+ this.#controls.name = this.#fields.name.querySelector('input-ui');
455
+
456
+ this.#fields.cardNumber = this.#stampRow('cardNumber', {
457
+ label: 'Card number',
458
+ required: true,
459
+ area: 'card',
460
+ input: {
461
+ type: 'text',
462
+ inputmode: 'numeric',
463
+ autocomplete: 'cc-number',
464
+ placeholder: '1234 1234 1234 1234',
465
+ prefix: 'credit-card',
466
+ maxlength: 23, // 19 digits + 4 spaces (Maestro upper bound)
467
+ },
468
+ });
469
+ this.#controls.cardNumber = this.#fields.cardNumber.querySelector('input-ui');
470
+ // Suffix slot — brand badge stamped lazily by #syncBrandSuffix.
471
+ const brandSlot = document.createElement('span');
472
+ brandSlot.setAttribute('slot', 'trailing');
473
+ brandSlot.setAttribute('data-brand-badge', '');
474
+ this.#controls.cardNumber.appendChild(brandSlot);
475
+
476
+ this.#fields.expiry = this.#stampRow('expiry', {
477
+ label: 'Expiry',
478
+ required: true,
479
+ area: 'expiry',
480
+ input: {
481
+ type: 'text',
482
+ inputmode: 'numeric',
483
+ autocomplete: 'cc-exp',
484
+ placeholder: 'MM/YY',
485
+ maxlength: 7,
486
+ },
487
+ });
488
+ this.#controls.expiry = this.#fields.expiry.querySelector('input-ui');
489
+
490
+ this.#fields.cvc = this.#stampRow('cvc', {
491
+ label: 'CVC',
492
+ required: true,
493
+ area: 'cvc',
494
+ input: {
495
+ type: 'text',
496
+ inputmode: 'numeric',
497
+ autocomplete: 'cc-csc',
498
+ placeholder: '123',
499
+ maxlength: 4,
500
+ },
501
+ });
502
+ this.#controls.cvc = this.#fields.cvc.querySelector('input-ui');
503
+
504
+ // Country select — composed via <select-ui>. Options must be
505
+ // appended BEFORE the select-ui is added to the document, because
506
+ // <select-ui>.connected() parses declarative <option> children
507
+ // once on attach and discards them. After that we use the
508
+ // programmatic .options property to mutate the list.
509
+ this.#fields.country = this.#stampRow('country', {
510
+ label: 'Country',
511
+ required: true,
512
+ area: 'country',
513
+ kind: 'select',
514
+ input: {
515
+ autocomplete: 'country',
516
+ },
517
+ // Seed options from the resolved countries list at stamp time so
518
+ // select-ui's first parse sees them.
519
+ seedOptions: this.#resolveCountries(),
520
+ seedValue: this.defaultCountry || 'US',
521
+ });
522
+ this.#controls.country = this.#fields.country.querySelector('select-ui');
523
+
524
+ this.#fields.postalCode = this.#stampRow('postalCode', {
525
+ label: 'Postal code',
526
+ required: true,
527
+ area: 'postal',
528
+ input: {
529
+ type: 'text',
530
+ inputmode: 'text',
531
+ autocomplete: 'postal-code',
532
+ placeholder: '94105',
533
+ maxlength: 12,
534
+ },
535
+ });
536
+ this.#controls.postalCode = this.#fields.postalCode.querySelector('input-ui');
537
+
538
+ // Billing-address lines — stamped now, hidden until needed.
539
+ this.#fields.addressLine1 = this.#stampRow('addressLine1', {
540
+ label: 'Address line 1',
541
+ required: false,
542
+ area: 'address1',
543
+ input: {
544
+ type: 'text',
545
+ autocomplete: 'address-line1',
546
+ placeholder: '123 Main St',
547
+ maxlength: 100,
548
+ },
549
+ });
550
+ this.#controls.addressLine1 = this.#fields.addressLine1.querySelector('input-ui');
551
+ this.#fields.addressLine1.hidden = true;
552
+
553
+ this.#fields.addressLine2 = this.#stampRow('addressLine2', {
554
+ label: 'Address line 2 (optional)',
555
+ required: false,
556
+ area: 'address2',
557
+ input: {
558
+ type: 'text',
559
+ autocomplete: 'address-line2',
560
+ placeholder: 'Apt 4B',
561
+ maxlength: 100,
562
+ },
563
+ });
564
+ this.#controls.addressLine2 = this.#fields.addressLine2.querySelector('input-ui');
565
+ this.#fields.addressLine2.hidden = true;
566
+
567
+ // Legal slot probe — we don't stamp legal content; consumers slot
568
+ // their own. Stamp a wrapper so :scope > [data-legal] gates the
569
+ // CSS-controlled styling (color + size).
570
+ const legalHost = document.createElement('div');
571
+ legalHost.setAttribute('data-legal', '');
572
+ this.appendChild(legalHost);
573
+ this.#legalHost = legalHost;
574
+
575
+ // Promote any author-provided slot="legal" children into the
576
+ // [data-legal] region so the slot mechanism stays decorative.
577
+ for (const node of [...this.children]) {
578
+ if (node === legalHost) continue;
579
+ if (node.getAttribute && node.getAttribute('slot') === 'legal') {
580
+ legalHost.appendChild(node);
581
+ }
582
+ }
583
+ }
584
+
585
+ #stampRow(key, opts) {
586
+ const field = document.createElement('field-ui');
587
+ field.setAttribute('label', opts.label);
588
+ if (opts.required) field.setAttribute('required', '');
589
+ field.setAttribute('data-field', key);
590
+ field.style.gridArea = opts.area;
591
+
592
+ let ctrl;
593
+ if (opts.kind === 'select') {
594
+ ctrl = document.createElement('select-ui');
595
+ // Seed declarative <option> children BEFORE the element is added
596
+ // to the DOM so select-ui's connected() parse picks them up.
597
+ if (Array.isArray(opts.seedOptions)) {
598
+ for (const o of opts.seedOptions) {
599
+ const optEl = document.createElement('option');
600
+ optEl.value = o.value;
601
+ optEl.textContent = o.label;
602
+ if (opts.seedValue && o.value === opts.seedValue) optEl.setAttribute('selected', '');
603
+ ctrl.appendChild(optEl);
604
+ }
605
+ }
606
+ } else {
607
+ ctrl = document.createElement('input-ui');
608
+ }
609
+ ctrl.setAttribute('name', key);
610
+ ctrl.setAttribute('data-control', key);
611
+ if (opts.required) ctrl.setAttribute('required', '');
612
+ for (const [attr, val] of Object.entries(opts.input || {})) {
613
+ if (val == null || val === '') continue;
614
+ ctrl.setAttribute(attr, String(val));
615
+ }
616
+ field.appendChild(ctrl);
617
+
618
+ this.#gridHost.appendChild(field);
619
+ return field;
620
+ }
621
+
622
+ /** Resolve the active countries list (props || defaults). */
623
+ #resolveCountries() {
624
+ const list = Array.isArray(this.countries) && this.countries.length > 0
625
+ ? this.countries
626
+ : DEFAULT_COUNTRIES;
627
+ return list.map((code) => ({ value: code, label: countryName(code) }));
628
+ }
629
+
630
+ // ── Internal — wiring ─────────────────────────────────────────────
631
+
632
+ #wireListeners() {
633
+ // Card-number input → digit-only normalization + brand detect.
634
+ const onCardInput = (e) => {
635
+ if (!this.#controls.cardNumber || e.target !== this.#controls.cardNumber) return;
636
+ const raw = String(this.#controls.cardNumber.value || '');
637
+ const digits = digitsOnly(raw);
638
+ const brand = detectBrand(digits);
639
+ if (brand !== this.#brand) {
640
+ this.#brand = brand;
641
+ this.#syncBrandSuffix();
642
+ }
643
+ this.#emitChange();
644
+ };
645
+ this.addEventListener('input', onCardInput);
646
+ this.#cleanups.push(() => this.removeEventListener('input', onCardInput));
647
+
648
+ // Any field change → re-emit composite change. Guard against the
649
+ // self-re-fire: `#emitChange` dispatches a bubbling `change` on the
650
+ // host (so consumer `<form>` listeners fire); the host itself is
651
+ // also listening, which without this guard would recurse to stack
652
+ // overflow. Only react to events from DESCENDANT fields, never to
653
+ // our own re-dispatch. Caught 2026-05-24 by the test agent when
654
+ // simulated card-input recursed via #emitChange ↔ change listener.
655
+ const onAnyChange = (e) => {
656
+ if (e.target === this) return;
657
+ this.#emitChange();
658
+ };
659
+ this.addEventListener('change', onAnyChange);
660
+ this.#cleanups.push(() => this.removeEventListener('change', onAnyChange));
661
+
662
+ // Edit-card → return to full-form mode.
663
+ const onEditClick = () => {
664
+ if (this.disabled) return;
665
+ this.removeAttribute('prefilled');
666
+ // Don't clear `prefill` attr — consumer may still want it as a
667
+ // hydrator for the next render. We hide the summary via state.
668
+ if (this.#summaryHost) this.#summaryHost.hidden = true;
669
+ if (this.#gridHost) this.#gridHost.hidden = false;
670
+ };
671
+ this.#editButton.addEventListener('click', onEditClick);
672
+ this.#editButton.addEventListener('press', onEditClick);
673
+ this.#cleanups.push(() => {
674
+ this.#editButton.removeEventListener('click', onEditClick);
675
+ this.#editButton.removeEventListener('press', onEditClick);
676
+ });
677
+
678
+ // Outer-form submit → tokenize (if inside a <form>).
679
+ // We listen on the host for 'invalid' so we can short-circuit
680
+ // the browser's default-prevented submit path.
681
+ // tokenize() is the canonical commit; consumers wire their own
682
+ // submit handler that calls tokenize() (see anti_patterns example).
683
+ }
684
+
685
+ #emitChange() {
686
+ const cardDigits = digitsOnly(this.#controls.cardNumber?.value);
687
+ const brand = detectBrand(cardDigits);
688
+ const validity = this.#peekValidity();
689
+ this.dispatchEvent(new CustomEvent('change', {
690
+ bubbles: true,
691
+ detail: { valid: validity.ok, brand },
692
+ }));
693
+ }
694
+
695
+ // ── Internal — render-side syncs ──────────────────────────────────
696
+
697
+ #syncCountries() {
698
+ const sel = this.#controls.country;
699
+ if (!sel) return;
700
+
701
+ // <select-ui> parses native <option> children on its own connect.
702
+ // We seeded them at stamp time so the first render is a no-op here.
703
+ // On subsequent renders (when `countries` prop changes), bypass the
704
+ // children path entirely and set `.options` via the programmatic
705
+ // API — the property setter performs the rebuild + uses `untracked`
706
+ // to avoid reactivity feedback.
707
+ const next = this.#resolveCountries();
708
+ const same = Array.isArray(sel.options)
709
+ && sel.options.length === next.length
710
+ && sel.options.every((o, i) => o?.value === next[i].value && o?.label === next[i].label);
711
+ if (!same) sel.options = next;
712
+
713
+ if (!sel.value) {
714
+ sel.value = this.defaultCountry || next[0]?.value || 'US';
715
+ }
716
+ }
717
+
718
+ #syncBillingRows() {
719
+ const show = !!this.requireBillingAddress;
720
+ if (this.#fields.addressLine1) this.#fields.addressLine1.hidden = !show;
721
+ if (this.#fields.addressLine2) this.#fields.addressLine2.hidden = !show;
722
+ }
723
+
724
+ #syncDisabled() {
725
+ const dis = !!this.disabled;
726
+ for (const ctrl of Object.values(this.#controls)) {
727
+ if (!ctrl) continue;
728
+ if (dis) ctrl.setAttribute('disabled', '');
729
+ else ctrl.removeAttribute('disabled');
730
+ }
731
+ }
732
+
733
+ #syncAlert() {
734
+ if (!this.#alertHost) return;
735
+ const msg = this.error || '';
736
+ if (msg) {
737
+ this.#alertHost.setAttribute('text', msg);
738
+ this.#alertHost.hidden = false;
739
+ this.setAttribute('aria-invalid', 'true');
740
+ } else {
741
+ this.#alertHost.removeAttribute('text');
742
+ this.#alertHost.hidden = true;
743
+ this.removeAttribute('aria-invalid');
744
+ }
745
+ }
746
+
747
+ #syncSummary() {
748
+ if (!this.#summaryHost) return;
749
+ const prefill = this.#parsePrefill();
750
+ if (!prefill || this.hasAttribute('prefilled-dismissed')) {
751
+ this.#summaryHost.hidden = true;
752
+ if (this.#gridHost) this.#gridHost.hidden = false;
753
+ this.removeAttribute('prefilled');
754
+ return;
755
+ }
756
+ const text = this.#summaryHost.querySelector(':scope > [data-summary-text]');
757
+ if (text) {
758
+ const brand = (prefill.brand || 'Card').toUpperCase();
759
+ const last4 = prefill.last4 || '••••';
760
+ const exp = prefill.expMonth && prefill.expYear
761
+ ? ` · expires ${String(prefill.expMonth).padStart(2, '0')}/${String(prefill.expYear).slice(-2)}`
762
+ : '';
763
+ text.textContent = `${brand} ending in ${last4}${exp}`;
764
+ }
765
+ this.#summaryHost.hidden = false;
766
+ if (this.#gridHost) this.#gridHost.hidden = true;
767
+ this.setAttribute('prefilled', '');
768
+ }
769
+
770
+ #syncBrandSuffix() {
771
+ const ctrl = this.#controls.cardNumber;
772
+ if (!ctrl) return;
773
+ const slot = ctrl.querySelector(':scope > [data-brand-badge]');
774
+ if (!slot) return;
775
+ if (this.#brand === 'unknown' || !this.#brand) {
776
+ slot.replaceChildren();
777
+ return;
778
+ }
779
+ const existing = slot.querySelector(':scope > [data-brand]');
780
+ if (existing && existing.getAttribute('data-brand') === this.#brand) return;
781
+ slot.replaceChildren();
782
+ const badge = document.createElement('span');
783
+ badge.setAttribute('data-brand', this.#brand);
784
+ badge.textContent = this.#brand.toUpperCase();
785
+ slot.appendChild(badge);
786
+ }
787
+
788
+ // ── Internal — validation ─────────────────────────────────────────
789
+
790
+ /** Peek current validity for `change` events — no side-effects on fields. */
791
+ #peekValidity() {
792
+ const digits = digitsOnly(this.#controls.cardNumber?.value);
793
+ if (!digits) return { ok: false };
794
+ if (!luhn(digits)) return { ok: false };
795
+ if (validateExpiry(this.#controls.expiry?.value)) return { ok: false };
796
+ if (validateCvc(this.#controls.cvc?.value, detectBrand(digits))) return { ok: false };
797
+ if (!(this.#controls.name?.value || '').trim()) return { ok: false };
798
+ if (!(this.#controls.postalCode?.value || '').trim()) return { ok: false };
799
+ return { ok: true };
800
+ }
801
+
802
+ /** Validate every field, surface per-field error attrs, return result. */
803
+ #validateAll() {
804
+ // Clear previous per-field errors.
805
+ for (const ctrl of Object.values(this.#controls)) ctrl?.removeAttribute?.('error');
806
+
807
+ const name = String(this.#controls.name?.value || '').trim();
808
+ if (!name) {
809
+ this.#controls.name?.setAttribute('error', 'Name on card is required.');
810
+ return { ok: false, message: 'Name on card is required.', code: 'validation-failed', field: 'name' };
811
+ }
812
+
813
+ const rawCard = this.#controls.cardNumber?.value || '';
814
+ const digits = digitsOnly(rawCard);
815
+ if (!digits) {
816
+ this.#controls.cardNumber?.setAttribute('error', 'Card number is required.');
817
+ return { ok: false, message: 'Card number is required.', code: 'validation-failed', field: 'cardNumber' };
818
+ }
819
+ if (!luhn(digits)) {
820
+ this.#controls.cardNumber?.setAttribute('error', 'Card number is invalid.');
821
+ return { ok: false, message: 'Card number is invalid.', code: 'card-invalid', field: 'cardNumber' };
822
+ }
823
+
824
+ const expiryErr = validateExpiry(this.#controls.expiry?.value || '');
825
+ if (expiryErr) {
826
+ const msg =
827
+ expiryErr === 'expiry-past' ? 'Card has expired.' :
828
+ expiryErr === 'expiry-month' ? 'Expiry month must be 01-12.' :
829
+ expiryErr === 'expiry-far' ? 'Expiry year is too far in the future.' :
830
+ 'Expiry must be MM/YY.';
831
+ this.#controls.expiry?.setAttribute('error', msg);
832
+ return { ok: false, message: msg, code: expiryErr === 'expiry-past' ? 'expired' : 'validation-failed', field: 'expiry' };
833
+ }
834
+
835
+ const brand = detectBrand(digits);
836
+ const cvcErr = validateCvc(this.#controls.cvc?.value || '', brand);
837
+ if (cvcErr) {
838
+ const wantLen = brand === 'amex' ? 4 : 3;
839
+ const msg = `CVC must be ${wantLen} digits.`;
840
+ this.#controls.cvc?.setAttribute('error', msg);
841
+ return { ok: false, message: msg, code: 'cvc-invalid', field: 'cvc' };
842
+ }
843
+
844
+ const country = String(this.#controls.country?.value || this.defaultCountry || 'US');
845
+ const postal = String(this.#controls.postalCode?.value || '').trim();
846
+ if (!postal) {
847
+ this.#controls.postalCode?.setAttribute('error', 'Postal code is required.');
848
+ return { ok: false, message: 'Postal code is required.', code: 'validation-failed', field: 'postalCode' };
849
+ }
850
+
851
+ if (this.requireBillingAddress) {
852
+ const line1 = String(this.#controls.addressLine1?.value || '').trim();
853
+ if (!line1) {
854
+ this.#controls.addressLine1?.setAttribute('error', 'Address is required.');
855
+ return { ok: false, message: 'Address is required.', code: 'validation-failed', field: 'addressLine1' };
856
+ }
857
+ }
858
+
859
+ const parsed = parseExpiry(this.#controls.expiry.value);
860
+ const last4 = digits.slice(-4);
861
+ const detail = {
862
+ last4,
863
+ brand,
864
+ expMonth: parsed.month,
865
+ expYear: parsed.year,
866
+ name,
867
+ country,
868
+ };
869
+ return { ok: true, detail };
870
+ }
871
+
872
+ // ── Internal — helpers ────────────────────────────────────────────
873
+
874
+ /** Parse the `prefill` JSON-encoded string, return null on failure. */
875
+ #parsePrefill() {
876
+ const raw = this.prefill || this.getAttribute('prefill') || '';
877
+ if (!raw) return null;
878
+ try {
879
+ const parsed = JSON.parse(raw);
880
+ if (!parsed || typeof parsed !== 'object') return null;
881
+ return parsed;
882
+ } catch {
883
+ return null;
884
+ }
885
+ }
886
+ }