Skip to content

Localize the widget into 34 languages, and give it an accessible name - #30

Merged
cport1 merged 1 commit into
mainfrom
feat/i18n
Aug 20, 2026
Merged

Localize the widget into 34 languages, and give it an accessible name#30
cport1 merged 1 commit into
mainfrom
feat/i18n

Conversation

@cport1

@cport1 cport1 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

The widget's entire user-facing vocabulary was five hardcoded English strings. They are now a bundled table of 34 languages, resolved automatically.

Language resolution

First candidate that names a shipped language wins: explicit lang option → data-lang on the container → the page's <html lang>navigator.language → English. The page outranks the browser deliberately — a widget should match the form around it rather than the reader's locale. Region tags fall back to their base (de-ATde); pt-BR and zh-TW have their own entries where the wording differs.

Bundled rather than fetched: this is a security control on someone else's login form, so a runtime language-pack request would be another origin to trust, another thing to fail closed on a flaky network, and a delay on the critical path. ~3 KB before compression.

A strings option overrides any key, so an unshipped language is not a blocker. Those are HTML-escaped — they may come from a CMS or locale file.

Arabic, Hebrew and Persian set dir="rtl" and flip the row.

Accessibility, fixed in passing

Both defects were on the exact lines being translated:

  • The control had no accessible name. role="checkbox" sat beside an unassociated sibling span, so screen readers announced "checkbox, not checked" and nothing more. Now wired with aria-labelledby.
  • No state change was announced. Verifying / verified / failed are all conveyed by swapping that label's text. Now aria-live="polite".

The widget root also carries its resolved lang, so the label is pronounced correctly rather than as English.

A charset bug the translations depend on

A classic script served without a charset is decoded using the document's encoding, so every non-ASCII string renders as mojibake on any page that is not already UTF-8. Node was correct via Express; Go and Python both served a bare application/javascript. Caught by a test fixture, which is the only reason it did not ship.

A browser test that was asserting the wrong thing

pow.spec.ts expected a Playwright-driven Chromium to receive a token — which asks the captcha to fail at its job, since navigator.webdriver is Dispositive and correctly floors the score at 0.9. It passed only until that floor shipped in v1.18.0, and nothing noticed because test/browser runs nowhere in CI. It now asserts what its describe block is about — that the parallel solver's output was accepted — and the browser suite runs in CI.

/api/score also gained the reason field Go's /api/verify already reported, so all three servers explain a withheld token identically.

Tests

16 browser tests: resolution order, region fallback, unknown-tag fallback, overrides, escaping, RTL layout, accessible name, live region, charset, and that every shipped language renders something. Green against all three servers.

Bench unchanged — human FPR 0.00%, TPR 97.33%, gate exits 0. Canvas fingerprint strings verified byte-identical.

…ccessible name

The widget's entire user-facing vocabulary was five hardcoded English strings,
which is a hard blocker for any non-English market. They are now a bundled table
of 34 languages, resolved automatically.

Language is taken from the first of these that names a language we ship: an
explicit `lang` option, `data-lang` on the container, the page's `<html lang>`,
`navigator.language`, English. The page outranks the browser deliberately — a
German speaker reading an explicitly Japanese page is better served by a widget
that matches the form around it. Region tags fall back to their base language, so
de-AT resolves to de; pt-BR and zh-TW have their own entries where the wording
actually differs.

Bundled rather than fetched: the widget is a security control on someone else's
login form, and a runtime request for a language pack would be another origin to
trust, another thing to fail closed on a flaky network, and a render delay on the
critical path. 34 languages of five short strings is about 3 KB before
compression.

A `strings` option overrides any key, so an integrator whose language we do not
ship is not blocked on us adding it. Those are HTML-escaped — they may well come
from a CMS or locale file, and interpolating them raw would make the widget an
injection point on the form it protects.

Arabic, Hebrew and Persian set dir="rtl" and flip the row. The widget is a
horizontal strip — checkbox, label, branding — so translating without this would
leave the layout reading backwards.

Two accessibility defects fixed in passing, both on the lines being translated:

  - The control had no accessible name. `role="checkbox"` sat next to an
    unassociated sibling span, so screen readers announced "checkbox, not
    checked" and nothing else. Now wired with aria-labelledby.
  - No state change was announced. Verifying, verified and failed are all
    communicated by swapping that label's text; a sighted user sees it and
    nobody else was told. The label is now aria-live="polite".

The widget root also carries its resolved `lang`, so the label is pronounced
with the right voice rather than as English.

Fixes the charset the translations depend on. A classic script served without one
is decoded using the *document's* encoding, so every non-ASCII string would
render as mojibake on any page that is not already UTF-8. Node was correct via
Express; Go and Python both served a bare `application/javascript`. Caught by a
test fixture, which is the only reason it did not ship.

Also corrects a browser test that had been asserting the wrong thing. It expected
a Playwright-driven Chromium to receive a token — which asks the captcha to fail
at its job, since navigator.webdriver is Dispositive and correctly floors the
score at 0.9. It passed only until that floor shipped in v1.18.0, and nothing
noticed because test/browser runs nowhere in CI. It now asserts what its describe
block is actually about: that the parallel solver's output was accepted. And the
browser suite now runs in CI, so the next one fails loudly.

/api/score gained the `reason` field Go's /api/verify already reported, so all
three servers and both endpoints explain a withheld token the same way.

Tests: 16 browser tests covering resolution order, region fallback, unknown-tag
fallback, overrides, escaping, RTL, the accessible name, the live region, the
charset, and that every shipped language renders something. Green against all
three servers. Bench unchanged: human FPR 0.00%, TPR 97.33%, gate exits 0.
@cport1
cport1 merged commit 553d4b0 into main Aug 20, 2026
6 checks passed
@cport1
cport1 deleted the feat/i18n branch August 20, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant