Constraining a Model to Propose Accessible Names That Are Safe to Ship

A suggested accessible name is safe when it cannot say something the interface does not already say. That single property is what the rules on this page enforce, and it is enforced twice — once as a hard rule inside the request, and once as a deterministic filter on the response, because a rule stated in a prompt is a preference and a rule applied to the output is a guarantee. This guide is part of AI-assisted accessibility remediation, which covers the wider operating model that surrounds the suggestion step.

Root Cause

The accessible-name computation has a priority order, and aria-label sits near the top of it: when present, it beats the element’s own text content, it beats a wrapping <label>, and it beats everything a sighted user can read. That makes aria-label the single most dangerous attribute a generator can write. It is also the attribute a model reaches for first, because in the corpus of markup on the public web aria-label is by far the most common way an icon control gets a name, so “suggest a label for this button” reliably produces an aria-label even when the control already has visible text that would have been a better name.

The consequence is WCAG 2.2 SC 2.5.3 (Label in Name), which requires that the accessible name of a control with visible text contains that text. A name of “Remove order” on a control whose visible label reads “Delete order” is a failure: a speech-input user says the words they can see, the command does not match, and nothing happens. Nothing in axe-core or any other scanner can detect it, because a scanner has no way to know which of the two strings the user is looking at. Preferring a real <label> or the element’s own text content over an aria-label is not stylistic advice — it removes the possibility of the mismatch, because there is only one string. An aria-label is a second copy of a label, and second copies drift the first time somebody changes the visible one.

Four more failure shapes come out of an unconstrained request, and each has a mechanical rejection. A model will invent information the interface does not present — an order number it inferred from a URL, a price, a count of items — which sounds authoritative and is unverifiable. It will produce content-free phrasing such as “click here”, “icon” or “button”, which satisfies every rule and helps nobody, and which is worst in a screen reader’s list-of-controls view where names appear stripped of all surrounding context. It will duplicate a sibling’s name, so a table of eight rows announces eight identical “Edit” controls. And it will pad the name into a sentence, when a description belongs in aria-describedby and a name should be short enough to speak.

Preferred sources for a suggested accessible name A ranked stack of five name sources. Rank one is a label element and rank two is the element's own text content, both marked as having no possible drift. Rank three is aria-labelledby pointing at the rendered text. Rank four is an aria-label that contains the visible text verbatim. Rank five is an aria-label with no visible text at all, permitted only when the meaning is visible some other way, otherwise the suggestion must abstain. Where the suggested name should come from, best first 1 A real label element bound to the control the visible string is the name, so the two can never drift apart 2 The element's own visible text content same guarantee for buttons and links; add text, do not add an attribute 3 aria-labelledby pointing at the rendered text reuses the visible string by reference rather than copying it 4 aria-label that contains the visible text verbatim only when the name must add a distinguishing detail, never to replace 5 aria-label where there is no visible text at all allowed only if the meaning is visible some other way, else abstain Ranks 1 to 3 make SC 2.5.3 unbreakable by construction; only ranks 4 and 5 need a filter.
The ranking is the first constraint: a suggestion that reaches for rank four when rank one or two was available has chosen the only option that can later disagree with the screen.

Configuration

The request states the rules as numbered refusals rather than as preferences, and it numbers them in the same order the post-filter applies them, so a rejection message like R4 means the same thing on both sides. Three details do the heavy lifting. The visible text is supplied as a field in the payload rather than left for the model to locate, so the containment rule has an unambiguous referent. Abstention is an explicit, valid answer with an enumerated reason. And the response must declare which visible thing the name came from, from a closed list that contains no option meaning “inferred” — a model that cannot name its source has to abstain.

// a11y/ai/name-prompt.mjs — the request, with the rules stated as refusals.
export function buildNameRequest(payload) {
  const visible = payload.visibleLabel || payload.visibleText || '';
  return [
    'You propose ONE accessible name for ONE control. You do not apply it.',
    'Reply with a single JSON object matching the schema. No prose.',
    '',
    'HARD RULES. Breaking any of them means you must abstain instead:',
    'R1 The name is trimmed, at least 2 characters, and is not only punctuation.',
    'R2 The name is at most 90 characters. A description is not a name.',
    'R3 The name contains no angle brackets, no ampersands and no newline.',
    visible
      ? `R4 The name MUST contain this visible text verbatim: "${visible}".`
      : 'R4 There is no visible text on this control; do not fabricate one.',
    'R5 The name is not a content-free phrase such as "click here" or "icon".',
    'R6 The name does not end in a role word such as "button" or "link".',
    `R7 The name is not one of these sibling names: ${
      JSON.stringify(payload.siblingNames ?? [])}.`,
    'R8 The name contains no number, filename, URL or fact that is absent',
    '   from the visible strings supplied below. Never read meaning from src.',
    '',
    'You MUST set abstain to true when the purpose of this control is not',
    'visible in the material below. Abstaining is a correct answer.',
    '',
    `fingerprint: ${payload.fingerprint}`,
    `attribute you are naming: ${payload.attribute}`,
    `document language: ${payload.documentLang}`,
    `visible text on the control: ${JSON.stringify(payload.visibleText)}`,
    `visible label bound to it: ${JSON.stringify(payload.visibleLabel)}`,
    `visible text beside it: ${JSON.stringify(payload.siblingText ?? [])}`,
    `ancestors: ${payload.ancestorPath}`,
    `element: ${payload.outerHTML}`,
    `component source near the finding:\n${payload.sourceSlice}`,
  ].join('\n');
}

The schema is deliberately narrow: eight properties, additionalProperties: false, and two enumerated fields. nameSource is the interesting one. Forcing the response to attribute the name to visible-text, visible-label, sibling-text or icon-glyph turns a fluent guess into a claim that the post-filter can test, and the absence of any value meaning “inferred from context” removes the escape hatch a generator would otherwise take.

// a11y/ai/name-schema.mjs — the only response shape the caller will accept.
export const NAME_RESPONSE_SCHEMA = {
  type: 'object',
  additionalProperties: false, // an extra field is a rejection, not a warning
  required: ['fingerprint', 'abstain'],
  properties: {
    fingerprint: { type: 'string', minLength: 8, maxLength: 64 },
    abstain: { type: 'boolean' },
    // Required when abstain is false, forbidden when it is true.
    name: { type: 'string', minLength: 2, maxLength: 90 },
    containsVisibleText: { type: 'boolean' },
    nameSource: {
      type: 'string',
      // No value here means "inferred": every name must point at something
      // a user can see on the page.
      enum: ['visible-text', 'visible-label', 'sibling-text', 'icon-glyph'],
    },
    // Required when abstain is true.
    abstainReason: {
      type: 'string',
      enum: ['no-visible-meaning', 'ambiguous-control', 'text-not-in-payload'],
    },
  },
};

A valid response for the delete control in an orders table, and a valid abstention for an icon whose purpose is not on screen:

{
  "fingerprint": "a3f19c48d2b7",
  "abstain": false,
  "name": "Delete order 4471",
  "containsVisibleText": true,
  "nameSource": "visible-text"
}
{
  "fingerprint": "77c1e9084ab2",
  "abstain": true,
  "abstainReason": "no-visible-meaning"
}

The post-filter re-derives every rule from the payload and the returned string. It trusts nothing the response asserts about itself — containsVisibleText: true is a claim to be checked, not a fact — and it returns every reason it found rather than the first, because a name that fails three rules should not be re-requested three times.

// a11y/ai/name-filter.mjs — deterministic rejection of unsafe names.
const CONTENT_FREE = new Set([
  'click here', 'here', 'more', 'read more', 'learn more', 'link', 'button',
  'image', 'graphic', 'icon', 'picture', 'photo', 'go', 'ok', 'submit form',
]);
const TRAILING_ROLE = /\s+(button|link|image|graphic|icon|menu item)$/i;
const SRC_LEAK = /(https?:|www\.|\.(png|jpe?g|svg|gif|webp|ico)\b)/i;

// Normalise for comparison only: collapse whitespace, drop case, compose
// accents so "café" typed two ways compares equal.
const norm = (s, lang = 'en') =>
  (s ?? '').normalize('NFC').replace(/\s+/g, ' ').trim().toLocaleLowerCase(lang);

export function filterName(value, payload, { max = 90 } = {}) {
  const reasons = [];
  const name = value ?? '';
  const lang = (payload.documentLang || 'en').split('-')[0];
  const n = norm(name, lang);

  if (name !== name.trim()) reasons.push('R1 leading or trailing whitespace');
  if (n.replace(/[^\p{L}\p{N}]/gu, '').length < 2) reasons.push('R1 no content');
  if (name.length > max) reasons.push(`R2 longer than ${max} characters`);
  if (/[<>&\n\r]/.test(name)) reasons.push('R3 contains markup or a newline');

  const visible = norm(payload.visibleLabel || payload.visibleText || '', lang);
  if (visible && !n.includes(visible)) {
    reasons.push(`R4 does not contain visible text "${visible}" (SC 2.5.3)`);
  }
  if (CONTENT_FREE.has(n)) reasons.push('R5 content-free phrase');
  if (TRAILING_ROLE.test(name)) reasons.push('R6 ends with a role word');

  const siblings = (payload.siblingNames ?? []).map((s) => norm(s, lang));
  if (siblings.includes(n)) reasons.push('R7 duplicates a sibling name');

  // R8: every digit run in the name must appear in something a user can read.
  const seen = norm([
    payload.visibleText, payload.visibleLabel,
    ...(payload.siblingText ?? []),
  ].filter(Boolean).join(' '), lang);
  for (const run of name.match(/\d+/g) ?? []) {
    if (!seen.includes(run)) reasons.push(`R8 invented number "${run}"`);
  }
  if (SRC_LEAK.test(name)) reasons.push('R8 contains a filename or URL');

  return { ok: reasons.length === 0, name, reasons };
}
Rule R4: the visible text must appear inside the name The visible text on the control is Delete order. Four candidate names are drawn as bars, with the portion matching the visible text shaded. Delete order and Delete order 4471 contain it and pass. Remove order paraphrases it and fails. Delete truncates it and fails. Visible text a sighted user reads on the control: "Delete order" Delete order R4 passes exact match Delete order 4471 R4 passes 4471 is on screen Remove order R4 rejects paraphrase, not a match Delete R4 rejects drops half the label Shaded teal marks the visible label appearing verbatim and contiguously inside the candidate name.
Containment is tested on the whole visible label, contiguously: a name may extend it but may never paraphrase, reorder or truncate it.

Validation

The filter is pure — no network, no browser, no DOM — so it is tested against fixed payloads in milliseconds and every rule gets both polarities. The table below is the fixture set; the test file that encodes it follows.

Candidate name Visible text Expected Rule
Delete order 4471 Delete order accept
Remove order Delete order reject R4
Delete Delete order reject R4
Search button (none) reject R6
click here (none) reject R5
Ship (none) reject R7
Open invoice 9902 (none) reject R8
Open /i/invoice.png (none) reject R8
// a11y/ai/name-filter.test.mjs — run with: node --test a11y/ai/
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterName } from './name-filter.mjs';

const labelled = {
  documentLang: 'en-GB',
  visibleText: 'Delete order',
  visibleLabel: '',
  siblingText: ['Order 4471', 'Ship'],
  siblingNames: ['Ship', 'Print invoice'],
};
const iconOnly = { ...labelled, visibleText: '', siblingText: ['Order 4471'] };

const reasonCodes = (r) => r.reasons.map((s) => s.slice(0, 2));

test('accepts a name that extends the visible label', () => {
  assert.equal(filterName('Delete order 4471', labelled).ok, true);
});

test('R4 rejects a paraphrase of the visible label', () => {
  assert.deepEqual(reasonCodes(filterName('Remove order', labelled)), ['R4']);
});

test('R4 rejects a truncation of the visible label', () => {
  assert.deepEqual(reasonCodes(filterName('Delete', labelled)), ['R4']);
});

test('R6 rejects a trailing role word', () => {
  assert.deepEqual(reasonCodes(filterName('Search button', iconOnly)), ['R6']);
});

test('R5 rejects a content-free phrase', () => {
  assert.deepEqual(reasonCodes(filterName('click here', iconOnly)), ['R5']);
});

test('R7 rejects a name a sibling control already uses', () => {
  assert.deepEqual(reasonCodes(filterName('Ship', iconOnly)), ['R7']);
});

test('R8 rejects a number that is not visible anywhere', () => {
  assert.deepEqual(reasonCodes(filterName('Open invoice 9902', iconOnly)), ['R8']);
});

test('R8 keeps a number that is visible beside the control', () => {
  assert.equal(filterName('Open order 4471', iconOnly).ok, true);
});

test('R8 rejects a name read off the image source', () => {
  assert.deepEqual(reasonCodes(filterName('Open /i/invoice.png', iconOnly)), ['R8']);
});

test('reports every broken rule, not only the first', () => {
  const r = filterName('  remove order button  ', labelled);
  assert.equal(r.ok, false);
  assert.ok(r.reasons.length >= 2);
});

Running the suite prints one line per rule, which is the shape to keep in CI logs — a rejection that names its rule is a rejection somebody can act on:

node --test a11y/ai/
# ✔ accepts a name that extends the visible label (1.9ms)
# ✔ R4 rejects a paraphrase of the visible label (0.4ms)
# ✔ R4 rejects a truncation of the visible label (0.3ms)
# ✔ R6 rejects a trailing role word (0.3ms)
# ✔ R5 rejects a content-free phrase (0.2ms)
# ✔ R7 rejects a name a sibling control already uses (0.3ms)
# ✔ R8 rejects a number that is not visible anywhere (0.4ms)
# ✔ R8 keeps a number that is visible beside the control (0.2ms)
# ✔ R8 rejects a name read off the image source (0.3ms)
# ✔ reports every broken rule, not only the first (0.3ms)
# pass 10   fail 0
Survivors after each filter rule on one batch Horizontal bars shrink from 180 returned suggestions to 171 after schema validation, 142 after the visible-text containment rule, 131 after the content-free phrase rule, 124 after the sibling-uniqueness rule and 121 after the length ceiling. The drop at each stage is annotated to the right of the bar. One batch of 180 suggestions, filtered before any human sees them 180 responses returned by the model 171 match the response schema -9 142 contain the visible text verbatim (R4) -29 131 are not content-free or role-suffixed (R5, R6) -11 124 are unique among sibling controls (R7) -7 121 invent nothing and fit the ceiling (R2, R8) -3 59 suggestions were dropped without a person reading one of them. R4 is always the largest drop, which is the measurement that justifies the whole filter.
R4 removes more suggestions than every other rule combined, and each of those removals is a Label in Name failure that no scanner in the pipeline would have reported.

Edge Cases and Conditional Guards

  • Visible text that is not a text node. When the label lives in pixels — an image button, an SVG <text> glyph, a CSS content string — innerText returns nothing, visibleText is empty, and R4 cannot fire, which is exactly when the filter is weakest. Guard it: if the element is or contains a replaced image and visibleText is empty, force an abstention with text-not-in-payload rather than accepting a free-form name against no reference.
  • Icon-plus-text controls. A control with both a glyph and a word is governed entirely by the word. The correct suggestion for a trash icon beside the text “Delete” is alt="" on the icon and no attribute on the button; a name describing the glyph replaces a good label with a picture description and fails R4 on the way past.
  • Locale-specific casing and composition. Case folding is language-dependent, so a Turkish page normalised with a plain toLowerCase turns “İptal” into a string that no longer contains “iptal”, and a decomposed accent from one source will not match a composed one from another. Normalise with NFC and toLocaleLowerCase(documentLang) on both sides before the containment test — the same class of trap covered in testing internationalized labels in automated a11y workflows.

Pipeline Impact

The filter runs twice, in two different jobs, on purpose. Its first run is inside the drafting job immediately after the response arrives: rejected suggestions never become commits, never become pull requests and never consume a reviewer’s attention, so the filter’s cost is a few milliseconds against the cost of a review cycle. Its second run is inside the verification job, applied to the value actually present in the committed diff rather than to the value the drafting job intended, which closes the gap between what was proposed and what was written — the concern the whole pipeline in validating AI-generated ARIA fixes in CI is built around.

Emit per-rule rejection counts as a job artifact and chart them over time. The rates are diagnostic rather than cosmetic: a rising R4 rate means the payload has stopped supplying visible text, usually because a markup change moved the label into a node the capture step no longer reads. A rising R7 rate means the application has started rendering repeated controls without distinguishing context, which is a real design problem the filter has merely noticed. Neither number should ever gate a build on its own — the filter’s rejections are inputs to a drafting decision, and the merge decision belongs to the six conditions in the verification job.

Common Pitfalls

  • Trusting containsVisibleText from the response instead of recomputing containment from the payload; a model that has broken R4 will also assert that it has not.
  • Testing containment against textContent rather than rendered visible text, which pulls in visually-hidden strings and off-screen copy the user cannot read, and turns R4 into a rule that accepts almost anything.
  • Blocking the word “submit” or “search” as a role word. Those are legitimate names; only a trailing role word doubles the announcement, which is why R6 anchors to the end of the string.
  • Retrying a rejected suggestion with a stricter request until something passes, which converts a filter into a search for the one phrasing that slips through.
  • Applying the filter only in the drafting job, so a value edited by hand on the branch — or mangled by a serialiser escaping the string — reaches the default branch unchecked.
  • Letting the length ceiling do the work of a description rule: a 90-character name that reads like a sentence still passes R2 and still belongs in aria-describedby.

FAQ

Why require the visible text verbatim rather than allowing a close match? Because “close” has no machine-checkable definition and SC 2.5.3 has one. A fuzzy comparison — edit distance, token overlap, stemming — accepts “Remove order” against “Delete order” at almost any threshold anybody would pick, and that is precisely the failure the rule exists to catch. Verbatim containment is a bright line, it is cheap to test, and when it rejects a name that a human judges acceptable, the correct response is usually to fix the visible label rather than to loosen the rule.

Should the filter reject a name that is merely bad rather than unsafe? No, and keeping that distinction sharp is what makes the filter trustworthy. Its job is to reject names that are provably wrong against a rule: not containing the visible text, containing a number nobody can see, duplicating a sibling. Whether “Delete order 4471” is better phrasing than “Delete this order” is a judgement, and judgements belong to the reviewer. A filter that starts enforcing taste will start rejecting correct names, and a filter people override is not a filter.

How is a duplicate sibling name detected when the siblings have no names yet? Compare against whatever a user would currently hear, which is what the payload’s siblingNames field collects: each sibling control’s existing aria-label if it has one, otherwise its rendered text. In a table of unlabelled icon buttons every sibling is empty, so R7 cannot fire on the first pass and the collision only appears when the second suggestion in the same batch proposes the same string. That is why the drafting job compares names within a batch before opening any pull request, and why a batch is capped at a size a person can hold in their head.