AI coding means no more excuses for ignoring Localization and Accessibility
Coding agents change that. The exact qualities that made these tasks skippable — mechanical, repetitive, rule-bound, easy to specify — are the qualities an agent handles best. Adding a translated string across three files and wiring it into a widget is pure grind, and an agent doesn’t get bored, doesn’t cut corners under deadline, and doesn’t “leave it for the next sprint.” The overhead excuse is gone.
But only if you set the agent up to carry the load. Out of the box, an agent will happily hardcode a screen-reader label or machine-translate a term into nonsense — because nothing told it not to. The leverage isn’t in the model; it’s in how you configure it. This post shows what that configuration looks like, using the actual changes we made to a real Flutter app (a card-game scorecard, localized into English, Spanish, and French) to make our agent genuinely good at localization and accessibility.
01Make the agent aware — encode the rules where it reads them
An agent follows the conventions it can see. Claude Code and similar tools
read project instruction files (“skills,”
AGENTS.md, CLAUDE.md) before they touch code.
That’s where your standards belong — not in a wiki the agent
never opens.
Our app uses per-topic skill files. The localization skill spells out the mechanical workflow so the agent does it the same way every time:
- Add the key to
app_en.arb(the template) with an@<key>block:description, plusplaceholdersfor any{arg}.- Mirror the value into
app_es.arbandapp_fr.arb. Every key must exist in every.arb(missing keys silently fall back to English).- Run
gen-l10n, thenanalyzeandtest.
That’s not glamorous, but it’s exactly the checklist a human skips step 2 of. The skill even hands the agent a parity check to run on itself, so a forgotten translation fails loudly instead of silently degrading to English:
python3 -c "import json;k=lambda f:{x for x in json.load(open(f)) if x[0]!='@'};\
b=k('lib/l10n/app_en.arb');[print(l,sorted(b-k(f'lib/l10n/app_{l}.arb'))) for l in('es','fr')]"
02Give the agent knowledge it cannot guess
Naive “just let the AI translate it” falls apart here, and a little curation makes an agent dramatically better. Our scorecard supports Mille Bornes, a classic card game whose terms are not literal translations across editions. Ask any model to translate “Safeties” into French and it will cheerfully produce “Sécurités.” The real French edition calls them Bottes. Machine translation here doesn’t just sound off — it’s wrong to anyone who owns the game.
So the skill encodes the authentic vocabulary, per locale, as a lookup table the agent uses instead of translating:
| Concept | en (US edition) | fr (authentic) | es (authentic) |
|---|---|---|---|
| safeties | Safeties | Bottes | Bottes |
| coupFourre | Coup Fourré | Coup fourré | Coup Fourré |
| safeTrip | Safe Trip | Voyage sans les 200 | Viaje seguro |
| shutOut | Shut-Out | Capot | Capote |
Never machine-translate game terms literally — each edition has its own canonical vocabulary.
en= official American terms;fr= authentic 1000 Bornes terms;es= authentic Spanish-edition terms.
This is the pattern that turns an agent from “generates plausible translations” into “produces correct localization”: give it the domain facts it can’t infer, in the place it looks, and it stops guessing. The same principle applies to any project with jargon, brand terms, or precise wording requirements.
03Teach the agent that accessibility must be localized
The sharpest rule we encoded is one most Flutter tutorials miss. A semantic label is text read aloud by a screen reader — so it is user-facing text, and it must be localized exactly like anything on screen. The skill states this once, as the canonical rule, and every other skill links to it:
Semantics.label,semanticLabel, andText(semanticsLabel:)ARE user-facing (screen readers) and must be localized — do not hardcode them. Use a*Labelkey suffix to mark semantics-only strings. Pass runtime values as placeholders:l10n.playerRoundScoreLabel(playerIdx + 1, round + 1).
That single sentence reframes accessibility as a localization problem the
agent already knows how to solve. Now, instead of hardcoding an English
string into a Semantics widget, the agent reaches for an
.arb key by reflex — and the label ships in Spanish and
French along with everything else.
The de-duplication is deliberate: the rule lives in one place, and the other skills defer to it. Three drifting copies of a rule teach an agent to reproduce your inconsistency; one authoritative copy plus pointers keeps its behavior sharp and gives you less context to maintain.
04Enforce it — because instructions are only a suggestion
Every rule above was already in the agent’s context when I audited the app — and I still found two hardcoded, English-only screen-reader labels shipping in a fully-localized UI:
// BEFORE — heard as English by Spanish and French screen-reader users
semanticLabel:
'Player ${widget.playerIdx + 1} Round ${widget.round + 1} Modal',
A rule in the prompt is a suggestion, weighed against everything
else competing for the model’s attention. Under pressure to make the
feature work, both humans and agents quietly drop the invisible
constraint. The only rule that holds is one a machine checks. So we wrote
a custom analyzer lint —
localize_semantic_labels — that walks the syntax tree
and fails on any hardcoded label:
class LocalizeSemanticLabels extends DartLintRule {
@override
void run(resolver, reporter, context) {
context.registry.addNamedExpression((node) {
if (_labelArgNames.contains(node.name.label.name) &&
_isHardcoded(node.expression)) {
reporter.atNode(node.expression, _code);
}
});
// ...and label: when the enclosing constructor is Semantics(...)
}
// 'Player $x' is a StringLiteral and gets flagged.
// l10n.fooLabel(...) is a MethodInvocation and passes.
bool _isHardcoded(Expression expr) => expr is StringLiteral;
}
Because it reads the AST rather than raw text, it scopes the
label: check to Semantics widgets only —
zero false positives on the dozens of other widgets that take a
label:. And it runs in two places that matter:
in-editor, so a hardcoded label lights up the instant
it’s written (including inside the agent’s own
edit-and-analyze loop), and in CI, gated by a single
Validate workflow:
- name: Analyze
run: flutter analyze
- name: Custom lints (localize_semantic_labels)
run: dart run custom_lint
A failing check lands right back in the agent’s context, where it becomes a signal the model corrects against on its next pass. Instructions steer; the check binds. The gap between them is exactly where invisible bugs like these two live.
The payoff
Fixing the two bugs was the kind of grind that historically never happens:
- Add
playerRoundModalLabelandplayerRoundPhaseSelectorLabelto three.arbfiles with authentic Spanish and French values, - Wire
both call sites to
AppLocalizations, - Regenerate
- Verify parity,
- Run the test suite.
The excuse was never that teams didn’t care about localization and accessibility — it was that the overhead made them easy to defer. Agents remove the overhead. What’s left is a one-time setup problem:
The four moves
- Encode your standards where the agent reads them — instruction and skill files, not a wiki. The agent follows what it can see.
- Give it the domain facts it can’t infer — authentic translations, brand terms, precise wording — as lookup tables, so it stops guessing.
-
Reframe accessibility as localization. If a string is
read aloud, it’s user-facing; it belongs in your translation
files. Audit your
semanticLabelandSemantics(label:)call sites — you’ll probably find a hardcoded one. - Enforce anything you truly care about with an executable check in the agent’s loop and in CI. A rule in the prompt is a suggestion; a rule in a lint is a constraint.
Set that up once, and “we’ll localize it later” — the most reliable lie in software — stops being true.
Comments
Post a Comment