diff --git a/research/form-associated-ce-poc/face-text-field-no-delegate.js b/research/form-associated-ce-poc/face-text-field-no-delegate.js
new file mode 100644
index 0000000000..33cc70f437
--- /dev/null
+++ b/research/form-associated-ce-poc/face-text-field-no-delegate.js
@@ -0,0 +1,103 @@
+/**
+ * Copyright 2026 Adobe. All rights reserved.
+ *
+ * Variant WITHOUT delegatesFocus.
+ * Tests whether removing delegatesFocus changes how internals.role
+ * and internals.ariaLabel interact with the accessibility tree.
+ *
+ * If delegatesFocus is what causes the shadow input to be the
+ * "focused" element, then without it the HOST should receive focus
+ * and the SR should read internals.ariaLabel from the host.
+ */
+export class FaceTextFieldNoDelegate extends HTMLElement {
+ static formAssociated = true;
+
+ static get observedAttributes() {
+ return [
+ 'value',
+ 'placeholder',
+ 'type',
+ 'use-internals-aria-label',
+ 'internals-role',
+ 'labelling-strategy',
+ ];
+ }
+
+ #internals;
+ #input;
+
+ constructor() {
+ super();
+ this.#internals = this.attachInternals();
+
+ // No delegatesFocus — focus stays on the host unless user clicks input
+ const shadow = this.attachShadow({ mode: 'open' });
+ shadow.innerHTML = `
+
+
+ `;
+
+ this.#input = shadow.getElementById('internal-input');
+ this.#input.addEventListener('input', () => {
+ this.#internals.setFormValue(this.#input.value);
+ });
+ }
+
+ connectedCallback() {
+ const role = this.getAttribute('internals-role');
+ if (role) {
+ this.#internals.role = role;
+ }
+
+ const label = this.getAttribute('use-internals-aria-label');
+ if (label) {
+ const strategy = this.getAttribute('labelling-strategy') || 'dual-write';
+ if (strategy === 'internals-only' || strategy === 'dual-write') {
+ this.#internals.ariaLabel = label;
+ }
+ if (strategy === 'input-only' || strategy === 'dual-write') {
+ this.#input.setAttribute('aria-label', label);
+ }
+ }
+
+ const placeholder = this.getAttribute('placeholder');
+ if (placeholder) {
+ this.#input.placeholder = placeholder;
+ }
+
+ const value = this.getAttribute('value');
+ if (value) {
+ this.#input.value = value;
+ }
+ }
+
+ get value() {
+ return this.#input?.value ?? '';
+ }
+
+ set value(v) {
+ if (this.#input) {
+ this.#input.value = v;
+ this.#internals.setFormValue(v);
+ }
+ }
+}
+
+customElements.define('face-text-field-no-delegate', FaceTextFieldNoDelegate);
diff --git a/research/form-associated-ce-poc/face-text-field.js b/research/form-associated-ce-poc/face-text-field.js
new file mode 100644
index 0000000000..52478a3feb
--- /dev/null
+++ b/research/form-associated-ce-poc/face-text-field.js
@@ -0,0 +1,434 @@
+/**
+ * Copyright 2026 Adobe. All rights reserved.
+ *
+ * Minimal form-associated text field custom element.
+ * Tests what ElementInternals does OUT OF THE BOX — no custom JS
+ * that resolves cross-root IDREFs or forwards text between scopes.
+ *
+ * What this component does:
+ * - static formAssociated = true
+ * - ElementInternals for form value, validation, role, and ARIA
+ * - labelling-strategy attribute to isolate which layer carries the name
+ * - Constraint validation (setValidity, reportValidity)
+ * - formResetCallback, formDisabledCallback, formStateRestoreCallback
+ *
+ * What this component does NOT do (intentionally removed):
+ * - No cross-root IDREF resolution
+ * - No reading text from light DOM elements by ID
+ * - No slotchange-based label forwarding
+ * - No ariaLabelledByElements / ariaDescribedByElements usage
+ */
+export class FaceTextField extends HTMLElement {
+ static formAssociated = true;
+
+ static get observedAttributes() {
+ return [
+ 'value',
+ 'name',
+ 'required',
+ 'pattern',
+ 'placeholder',
+ 'type',
+ 'disabled',
+ 'internal-label',
+ 'internal-help',
+ 'use-internals-aria-label',
+ 'internals-role',
+ 'labelling-strategy',
+ 'min-value',
+ 'aria-label',
+ 'aria-errormessage',
+ ];
+ }
+
+ #internals;
+ #input;
+ #shadowLabel;
+ #shadowHelp;
+
+ constructor() {
+ super();
+ this.#internals = this.attachInternals();
+
+ const shadow = this.attachShadow({
+ mode: 'open',
+ delegatesFocus: true,
+ });
+ shadow.innerHTML = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ this.#input = shadow.getElementById('internal-input');
+ this.#shadowLabel = shadow.getElementById('shadow-label');
+ this.#shadowHelp = shadow.getElementById('shadow-help');
+
+ this.#input.addEventListener('input', () => {
+ this.#syncValue();
+ this.#validate();
+ });
+
+ this.#input.addEventListener('change', () => {
+ this.#validate();
+ });
+ }
+
+ connectedCallback() {
+ this.#syncFromAttributes();
+ this.#syncValue();
+ this.#validate();
+ }
+
+ attributeChangedCallback(name, _oldVal, newVal) {
+ switch (name) {
+ case 'value':
+ if (this.#input && this.#input.value !== newVal) {
+ this.#input.value = newVal ?? '';
+ this.#syncValue();
+ }
+ break;
+ case 'placeholder':
+ if (this.#input) {
+ this.#input.placeholder = newVal ?? '';
+ }
+ break;
+ case 'type':
+ if (this.#input) {
+ this.#input.type = newVal ?? 'text';
+ }
+ break;
+ case 'required':
+ if (this.#input) {
+ this.#input.required = newVal !== null;
+ }
+ break;
+ case 'pattern':
+ if (this.#input) {
+ this.#input.pattern = newVal ?? '';
+ }
+ break;
+ case 'disabled':
+ if (this.#input) {
+ this.#input.disabled = newVal !== null;
+ }
+ break;
+ case 'internal-label':
+ this.#updateInternalLabel(newVal);
+ break;
+ case 'internal-help':
+ this.#updateInternalHelp(newVal);
+ break;
+ case 'use-internals-aria-label':
+ if (newVal !== null) {
+ this.#applyLabel(newVal);
+ }
+ break;
+ case 'internals-role':
+ if (newVal !== null) {
+ this.#internals.role = newVal;
+ }
+ break;
+ case 'aria-label':
+ if (newVal !== null) {
+ this.#applyLabel(newVal);
+ }
+ break;
+ case 'min-value':
+ this.#validate();
+ break;
+ case 'aria-errormessage':
+ break;
+ }
+ }
+
+ // -- Public form API -------------------------------------------------------
+
+ get form() {
+ return this.#internals.form;
+ }
+
+ get name() {
+ return this.getAttribute('name');
+ }
+
+ get type() {
+ return this.localName;
+ }
+
+ get value() {
+ return this.#input?.value ?? '';
+ }
+
+ set value(v) {
+ if (this.#input) {
+ this.#input.value = v;
+ this.#syncValue();
+ }
+ }
+
+ get validity() {
+ return this.#internals.validity;
+ }
+
+ get validationMessage() {
+ return this.#internals.validationMessage;
+ }
+
+ get willValidate() {
+ return this.#internals.willValidate;
+ }
+
+ checkValidity() {
+ this.#validate();
+ return this.#internals.checkValidity();
+ }
+
+ reportValidity() {
+ this.#validate();
+ return this.#internals.reportValidity();
+ }
+
+ formResetCallback() {
+ const defaultValue = this.getAttribute('value') ?? '';
+ this.#input.value = defaultValue;
+ this.#syncValue();
+ this.#validate();
+ this.#clearErrorState();
+ }
+
+ formDisabledCallback(disabled) {
+ this.#input.disabled = disabled;
+ if (disabled) {
+ this.setAttribute('disabled', '');
+ } else {
+ this.removeAttribute('disabled');
+ }
+ }
+
+ formStateRestoreCallback(state, _mode) {
+ this.#input.value = state ?? '';
+ this.#syncValue();
+ }
+
+ // -- Private methods -------------------------------------------------------
+
+ #syncFromAttributes() {
+ const attrs = { value: '', placeholder: '', type: 'text' };
+ for (const [attr, fallback] of Object.entries(attrs)) {
+ const val = this.getAttribute(attr) ?? fallback;
+ if (attr === 'value') {
+ this.#input.value = val;
+ } else {
+ this.#input[attr] = val;
+ }
+ }
+
+ if (this.hasAttribute('required')) {
+ this.#input.required = true;
+ }
+ if (this.hasAttribute('pattern')) {
+ this.#input.pattern = this.getAttribute('pattern');
+ }
+ if (this.hasAttribute('disabled')) {
+ this.#input.disabled = true;
+ }
+
+ const internalLabel = this.getAttribute('internal-label');
+ if (internalLabel) {
+ this.#updateInternalLabel(internalLabel);
+ }
+
+ const internalHelp = this.getAttribute('internal-help');
+ if (internalHelp) {
+ this.#updateInternalHelp(internalHelp);
+ }
+
+ const internalsRole = this.getAttribute('internals-role');
+ if (internalsRole) {
+ this.#internals.role = internalsRole;
+ }
+
+ const internalsAriaLabel = this.getAttribute('use-internals-aria-label');
+ if (internalsAriaLabel) {
+ this.#applyLabel(internalsAriaLabel);
+ }
+
+ const hostAriaLabel = this.getAttribute('aria-label');
+ if (hostAriaLabel) {
+ this.#applyLabel(hostAriaLabel);
+ }
+ }
+
+ #syncValue() {
+ this.#internals.setFormValue(this.#input.value);
+ }
+
+ #validate() {
+ const input = this.#input;
+
+ const minValue = this.getAttribute('min-value');
+ if (minValue !== null && input.value !== '') {
+ const num = parseFloat(input.value);
+ if (!isNaN(num) && num < parseFloat(minValue)) {
+ this.#internals.setValidity(
+ { rangeUnderflow: true },
+ `Value must be at least ${minValue}.`,
+ input
+ );
+ this.#showErrorState();
+ return;
+ }
+ }
+
+ if (!input.validity.valid) {
+ this.#internals.setValidity(
+ input.validity,
+ input.validationMessage,
+ input
+ );
+ this.#showErrorState();
+ } else {
+ this.#internals.setValidity({});
+ this.#clearErrorState();
+ }
+ }
+
+ #showErrorState() {
+ this.#internals.ariaInvalid = 'true';
+ const errorId = this.getAttribute('aria-errormessage');
+ if (errorId) {
+ const errorEl = document.getElementById(errorId);
+ if (errorEl) {
+ errorEl.hidden = false;
+ }
+ }
+ }
+
+ #clearErrorState() {
+ this.#internals.ariaInvalid = 'false';
+ const errorId = this.getAttribute('aria-errormessage');
+ if (errorId) {
+ const errorEl = document.getElementById(errorId);
+ if (errorEl) {
+ errorEl.hidden = true;
+ }
+ }
+ }
+
+ #updateInternalLabel(text) {
+ if (text) {
+ this.#shadowLabel.textContent = text;
+ this.#shadowLabel.hidden = false;
+ this.#input.setAttribute('aria-labelledby', 'shadow-label');
+ } else {
+ this.#shadowLabel.hidden = true;
+ this.#input.removeAttribute('aria-labelledby');
+ }
+ }
+
+ #updateInternalHelp(text) {
+ if (text) {
+ this.#shadowHelp.textContent = text;
+ this.#shadowHelp.hidden = false;
+ const existing = this.#input.getAttribute('aria-describedby') ?? '';
+ if (!existing.includes('shadow-help')) {
+ this.#input.setAttribute(
+ 'aria-describedby',
+ (existing + ' shadow-help').trim()
+ );
+ }
+ } else {
+ this.#shadowHelp.hidden = true;
+ }
+ }
+
+ get #labellingStrategy() {
+ return this.getAttribute('labelling-strategy') || 'dual-write';
+ }
+
+ /**
+ * Applies a label using the configured strategy.
+ * No cross-root resolution — just sets the value where configured.
+ */
+ #applyLabel(text) {
+ if (!text) {
+ return;
+ }
+ const strategy = this.#labellingStrategy;
+
+ if (strategy === 'internals-only' || strategy === 'dual-write') {
+ this.#internals.ariaLabel = this.#internals.ariaLabel || text;
+ }
+
+ if (strategy === 'input-only' || strategy === 'dual-write') {
+ if (!this.#input.hasAttribute('aria-labelledby')) {
+ this.#input.setAttribute('aria-label', text);
+ }
+ }
+ }
+}
+
+customElements.define('face-text-field', FaceTextField);
diff --git a/research/form-associated-ce-poc/index.html b/research/form-associated-ce-poc/index.html
new file mode 100644
index 0000000000..eb6dba49a7
--- /dev/null
+++ b/research/form-associated-ce-poc/index.html
@@ -0,0 +1,712 @@
+
+
+
+
+
+
+ FACE text field PoC: ElementInternals research
+
+
+
+ Form-associated custom element PoC: text field
+
+ Research artifact for ElementInternals and FACE patterns. Not a migration
+ of
+ sp-textfield
+ ; a standalone platform test.
+
+
+
+
+
+
+ 1. Native form participation
+
+
+
1a. FormData and submit
+
+
+ Submit or reset the form to see results.
+
+
+
+
+
+ 1b. Constraint validation (
+ required
+ +
+ pattern
+ )
+
+
+
+ Submit with invalid data to see constraint validation.
+
+
+
+
+
+ 1c. Disabled state via
+ fieldset
+
+
+
+ Field should be disabled via ancestor fieldset.
+
+
+
+
+
+
+
+ 2. IDREF matrix: label and description wiring
+
+
+ Each scenario tests whether
+ aria-labelledby
+ and
+ aria-describedby
+ resolve correctly across shadow DOM boundaries. Inspect the accessibility
+ tree in DevTools for each.
+
+
+
+
+
Scenario A: light DOM label and help as siblings
+
+ IDs are on elements
+ outside
+ the component; referenced via
+ aria-labelledby
+ and
+ aria-describedby
+ on the host.
+
+
+
+
+
+
+
Scenario B: IDs on slotted light DOM children
+
+ Consumer projects content into the component via slots; IDs live on the
+ slotted elements.
+
+
+
+
+
+
+
Scenario C: shadow-local IDREF resolution (NOT cross-root)
+
+ Label and help text are rendered
+ inside the same shadow root
+ as the input. The input's
+ aria-labelledby
+ references a shadow-scoped ID. This is
+ not
+ cross-root ARIA — both elements share the same tree scope.
+
+
+
+
+
+
+
Scenario D: cross-root; light DOM label + shadow input
+
+ A
+ <label for="...">
+ in light DOM targets the custom element host. The component uses
+ delegatesFocus
+ and
+ ElementInternals
+ to attempt forwarding.
+
+
+
+
+
+
+
+
+ 3. ElementInternals ARIA property APIs
+
+
+
3a. Isolated ARIA labelling strategies (one per field)
+
+ Each field uses exactly
+ one
+ labelling strategy in isolation. Inspect each in the accessibility tree
+ to determine which layer actually carries the accessible name to the
+ focused control.
+
+
+
+ Expected:
+ Only the shadow input's
+ aria-label
+ produces a SR announcement because
+ delegatesFocus
+ places focus there.
+ internals.ariaLabel
+ alone should NOT announce on the focused input.
+
+
+
+
+
+ 3c.
+ internals.role
+ and labelling constraints
+
+
+ When the host has an explicit role via
+ internals.role
+ , does that change how labelling works? Does the host become the
+ "primary" accessible element, or does
+ delegatesFocus
+ still route SR interaction to the shadow input?
+
+
+
+ Key question:
+ When
+ internals.role = "textbox"
+ and the host is the element with a role, does
+ internals.ariaLabel
+ finally get announced? Or does
+ delegatesFocus
+ still send focus to the shadow input, bypassing the host's role
+ entirely?
+
+
+
+
+
+ 3b.
+ setValidity()
+ +
+ aria-errormessage
+
+
+
+ Submit with a value under 18 to test error announcement.
+
+
+
+
+
+
+
+ 4. axe-core automated audit
+
+
+ Loads axe-core from CDN and runs against the full page. Results show
+ violations, passes, and incomplete checks relevant to FACE patterns.
+
+
+ Run axe-core audit
+
+ Click the button above to run axe-core.
+
+
+
+
+
+
+ 5. IDREF matrix results
+
+
+ This table checks
+ DOM state
+ (attributes, resolved text content) — not the actual accessibility tree.
+ DOM wiring is a necessary precondition but does not guarantee SR exposure.
+ Verify actual computed names in DevTools Accessibility panel or with a
+ screen reader.
+
+
+
+
+
+ Scenario
+ Label wiring
+ Description wiring
+ DOM name source
+ DOM description source
+ Status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/research/form-associated-ce-poc/package.json b/research/form-associated-ce-poc/package.json
new file mode 100644
index 0000000000..b57916b3be
--- /dev/null
+++ b/research/form-associated-ce-poc/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "face-textfield-poc",
+ "private": true,
+ "description": "Form-associated custom element PoC: text field with ElementInternals",
+ "scripts": {
+ "dev": "vite",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^6"
+ }
+}
diff --git a/research/form-associated-ce-poc/page-handlers.js b/research/form-associated-ce-poc/page-handlers.js
new file mode 100644
index 0000000000..1870b52b50
--- /dev/null
+++ b/research/form-associated-ce-poc/page-handlers.js
@@ -0,0 +1,263 @@
+/**
+ * Copyright 2026 Adobe. All rights reserved.
+ *
+ * Page-level form handlers, matrix introspection, and axe-core integration.
+ * Separated from the component code for easier navigation.
+ */
+
+// Form 1a: submit + reset
+document.getElementById('form-1a').addEventListener('submit', (event) => {
+ event.preventDefault();
+ const data = new FormData(event.target);
+ const out = document.getElementById('output-1a');
+ const entries = [...data.entries()];
+ out.textContent =
+ 'FormData entries:\n' +
+ entries.map(([k, v]) => ` ${k}: "${v}"`).join('\n') +
+ '\n\nForm participation: ' +
+ (entries.length > 0 ? 'PASS' : 'FAIL');
+});
+
+document.getElementById('form-1a').addEventListener('reset', () => {
+ const out = document.getElementById('output-1a');
+ requestAnimationFrame(() => {
+ const field = document.getElementById('field-1a');
+ out.textContent = `Reset fired.\nValue after reset: "${field.value}"\nformResetCallback: ${field.value === 'Jane Doe' ? 'PASS' : 'FAIL'}`;
+ });
+});
+
+// Form 1b: constraint validation
+document.getElementById('form-1b').addEventListener('submit', (event) => {
+ event.preventDefault();
+ const field = document.getElementById('field-1b');
+ const out = document.getElementById('output-1b');
+ const valid = field.reportValidity();
+ out.textContent =
+ `reportValidity(): ${valid}\n` +
+ `validity.valid: ${field.validity.valid}\n` +
+ `validity.valueMissing: ${field.validity.valueMissing}\n` +
+ `validity.patternMismatch: ${field.validity.patternMismatch}\n` +
+ `validationMessage: "${field.validationMessage}"\n\n` +
+ `Constraint validation: ${!valid ? 'PASS (correctly rejected)' : 'Check input'}`;
+});
+
+// Form 1c: disabled fieldset
+document.getElementById('form-1c').addEventListener('submit', (event) => {
+ event.preventDefault();
+ const field = document.getElementById('field-1c');
+ const out = document.getElementById('output-1c');
+ const data = new FormData(event.target);
+ const shadowInput = field.shadowRoot?.querySelector('input');
+ out.textContent =
+ `Shadow input disabled: ${shadowInput?.disabled ?? 'N/A'}\n` +
+ `Host has disabled attr: ${field.hasAttribute('disabled')}\n` +
+ `formDisabledCallback: fires when fieldset is disabled\n` +
+ `FormData includes field: ${data.has('disabledfield') ? 'YES (unexpected)' : 'NO (correct)'}\n\n` +
+ `Disabled via fieldset: ${!data.has('disabledfield') ? 'PASS' : 'FAIL'}`;
+});
+
+// Form 3b: custom validation + ariaInvalid
+document.getElementById('form-3b').addEventListener('submit', (event) => {
+ event.preventDefault();
+ const field = document.getElementById('field-3b');
+ const shadowInput = field.shadowRoot?.querySelector('input');
+ const out = document.getElementById('output-3b');
+ const valid = field.reportValidity();
+ out.textContent =
+ `reportValidity(): ${valid}\n` +
+ `validity.valid: ${field.validity.valid}\n` +
+ `validity.rangeUnderflow: ${field.validity.rangeUnderflow}\n` +
+ `validationMessage: "${field.validationMessage}"\n\n` +
+ `--- internals.ariaInvalid observations ---\n` +
+ `host aria-invalid attr: ${field.getAttribute('aria-invalid') ?? '(not reflected)'}\n` +
+ `shadow input aria-invalid attr: ${shadowInput?.getAttribute('aria-invalid') ?? '(not set)'}\n\n` +
+ `Custom validation: ${!valid ? 'PASS (correctly rejected)' : 'Check input value'}`;
+});
+
+// IDREF matrix introspection (reads DOM state, not a11y tree)
+function populateMatrix() {
+ const scenarios = [
+ {
+ id: 'A',
+ name: 'Light DOM siblings',
+ fieldId: 'field-2a',
+ labelMethod: 'aria-labelledby on host → light DOM #label-2a',
+ descMethod: 'aria-describedby on host → light DOM #help-2a',
+ },
+ {
+ id: 'B',
+ name: 'Slotted light DOM children',
+ fieldId: 'field-2b',
+ labelMethod: 'Slotted with ID',
+ descMethod: 'Slotted with ID',
+ },
+ {
+ id: 'C',
+ name: 'Shadow DOM internal IDs',
+ fieldId: 'field-2c',
+ labelMethod: 'internal-label attr → shadow #shadow-label',
+ descMethod: 'internal-help attr → shadow #shadow-help',
+ },
+ {
+ id: 'D',
+ name: 'Cross-root (label for → host)',
+ fieldId: 'field-2d',
+ labelMethod: ' in light DOM',
+ descMethod: 'N/A (no describedby set)',
+ },
+ ];
+
+ const tbody = document.getElementById('matrix-body');
+ tbody.innerHTML = '';
+
+ for (const s of scenarios) {
+ const field = document.getElementById(s.fieldId);
+ if (!field) {
+ continue;
+ }
+
+ let domName = '(inspect DevTools)';
+ let domDesc = '(inspect DevTools)';
+ let status = 'pending';
+
+ try {
+ const ariaLabel = field.getAttribute('aria-label') || '';
+ const labelledby = field.getAttribute('aria-labelledby') || '';
+ const describedby = field.getAttribute('aria-describedby') || '';
+
+ if (ariaLabel) {
+ domName = `aria-label: "${ariaLabel}"`;
+ } else if (labelledby) {
+ const text = labelledby
+ .split(/\s+/)
+ .map((id) => document.getElementById(id)?.textContent?.trim())
+ .filter(Boolean)
+ .join(' ');
+ domName = text ? `Resolved: "${text}"` : `IDs not found: ${labelledby}`;
+ }
+
+ if (describedby) {
+ const text = describedby
+ .split(/\s+/)
+ .map((id) => document.getElementById(id)?.textContent?.trim())
+ .filter(Boolean)
+ .join(' ');
+ domDesc = text
+ ? `Resolved: "${text}"`
+ : `IDs not found: ${describedby}`;
+ }
+ } catch {
+ // introspection may fail
+ }
+
+ const row = document.createElement('tr');
+ row.innerHTML = `
+ ${s.id}: ${s.name}
+ ${escapeHtml(s.labelMethod)}
+ ${escapeHtml(s.descMethod)}
+ ${domName}
+ ${domDesc}
+ ${status}
+ `;
+ tbody.appendChild(row);
+ }
+}
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+requestAnimationFrame(() => {
+ requestAnimationFrame(populateMatrix);
+});
+
+// axe-core integration
+document.getElementById('run-axe').addEventListener('click', async () => {
+ const resultsEl = document.getElementById('axe-results');
+ resultsEl.textContent = 'Loading axe-core...';
+
+ try {
+ if (!window.axe) {
+ await loadScript(
+ 'https://cdn.jsdelivr.net/npm/axe-core@4.10.2/axe.min.js'
+ );
+ }
+
+ resultsEl.textContent = 'Running audit...';
+
+ const results = await window.axe.run(document, {
+ runOnly: {
+ type: 'tag',
+ values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'],
+ },
+ });
+
+ let html = '';
+
+ html += `Violations (${results.violations.length}) `;
+ if (results.violations.length === 0) {
+ html += 'No violations found.
';
+ }
+ for (const v of results.violations) {
+ html += `
+
+
${escapeHtml(v.id)}: ${escapeHtml(v.help)}
+
Impact: ${v.impact} | Tags: ${v.tags.join(', ')}
+
${escapeHtml(v.description)}
+
+ Affected nodes (${v.nodes.length})
+
+ ${v.nodes.map((n) => `${escapeHtml(n.html)} ${escapeHtml(n.failureSummary ?? '')} `).join('')}
+
+
+ ${v.helpUrl ? `
Deque reference
` : ''}
+
+ `;
+ }
+
+ html += `Incomplete / needs review (${results.incomplete.length}) `;
+ for (const inc of results.incomplete) {
+ html += `
+
+
${escapeHtml(inc.id)} : ${escapeHtml(inc.help)}
+
Nodes: ${inc.nodes.length} | Tags: ${inc.tags.join(', ')}
+ ${inc.helpUrl ? ` |
Reference ` : ''}
+
+ `;
+ }
+
+ const relevantPasses = results.passes.filter((p) =>
+ [
+ 'label',
+ 'aria-label',
+ 'aria-labelledby',
+ 'form-field-multiple-labels',
+ 'aria-valid-attr',
+ 'aria-valid-attr-value',
+ 'aria-roles',
+ 'label-title-only',
+ 'aria-input-field-name',
+ ].some((id) => p.id.includes(id))
+ );
+ html += `Relevant passes (${relevantPasses.length} of ${results.passes.length} total) `;
+ for (const p of relevantPasses) {
+ html += `${escapeHtml(p.id)} : ${escapeHtml(p.help)}
`;
+ }
+
+ resultsEl.innerHTML = html;
+ } catch (err) {
+ resultsEl.textContent = `Error loading or running axe-core:\n${err.message}\n\nNote: axe-core requires network access to load from CDN.`;
+ }
+});
+
+function loadScript(src) {
+ return new Promise((resolve, reject) => {
+ const s = document.createElement('script');
+ s.src = src;
+ s.onload = resolve;
+ s.onerror = () => reject(new Error(`Failed to load ${src}`));
+ document.head.appendChild(s);
+ });
+}