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.
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 };
}
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
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 CSScontentstring —innerTextreturns nothing,visibleTextis empty, and R4 cannot fire, which is exactly when the filter is weakest. Guard it: if the element is or contains a replaced image andvisibleTextis empty, force an abstention withtext-not-in-payloadrather 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
toLowerCaseturns “İ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 andtoLocaleLowerCase(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
containsVisibleTextfrom 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
textContentrather 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.
Related
- AI-Assisted Accessibility Remediation — the operating model, the eligibility routing and the context payload these rules consume.
- Validating AI-Generated ARIA Fixes in CI — the gate that re-applies this filter to the committed value and blocks the merge.
- Custom Rule Development & Context-Aware Testing — how to turn a naming invariant such as sibling uniqueness into an axe check that runs on every page.