Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
langoption →data-langon 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-AT→de);pt-BRandzh-TWhave 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
stringsoption 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:
role="checkbox"sat beside an unassociated sibling span, so screen readers announced "checkbox, not checked" and nothing more. Now wired witharia-labelledby.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.tsexpected a Playwright-driven Chromium to receive a token — which asks the captcha to fail at its job, sincenavigator.webdriverisDispositiveand correctly floors the score at 0.9. It passed only until that floor shipped in v1.18.0, and nothing noticed becausetest/browserruns 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/scorealso gained thereasonfield Go's/api/verifyalready 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.