AI coding means no more excuses for ignoring Localization and Accessibility

The overhead that made teams skip internationalization and accessibility is exactly the kind of work coding agents do best. Here is how we set ours up to carry it.

Localization and accessibility have been the features teams “get to later” and never do, not out of malice, but out of friction. Localizing strings means tracking and extracting every label and every string. Every change means touching every language file, keeping them in parity, finding an authentic translation, and regenerating bindings. Accessibility means writing labels that no sighted reviewer will ever see, for a screen reader that no one on the team uses. Both are tedious, both are invisible in a demo, and both are the easiest thing on the board to defer under deadline. So they get deferred. Forever.

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:

  1. Add the key to app_en.arb (the template) with an @<key> block: description, plus placeholders for any {arg}.
  2. Mirror the value into app_es.arb and app_fr.arb. Every key must exist in every .arb (missing keys silently fall back to English).
  3. Run gen-l10n, then analyze and test.

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, and Text(semanticsLabel:) ARE user-facing (screen readers) and must be localized — do not hardcode them. Use a *Label key 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: 

  1. Add playerRoundModalLabel and playerRoundPhaseSelectorLabel to three .arb files with authentic Spanish and French values, 
  2. Wire both call sites to AppLocalizations
  3. Regenerate
  4. Verify parity, 
  5. Run the test suite. 
Every one of those steps is mechanical and rule-bound — and the agent did them in a single pass, guided by the skills and verified by the lint. What used to be a half-day of tedium that got punted indefinitely became a routine, checked, correct change.

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 semanticLabel and Semantics(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.

Part of the "Stuff we Push Off before LLMs" Series


Revision History

Created: 2026 07


Comments

Popular posts from this blog

Installing the RNDIS driver on Windows 11 to use USB Raspberry Pi as network attached

Home FIOS Network - Exploring the 4 boxes and their connections

Micro benchmarking Apple M1 Max - MLX vs GGUF - LLM QWEN 2.5