Repository navigation
Description
The gateway's human-verification step is not shown in the page's language. CapWidget overrides only three of the Cap widget's labels: the initial state, the solved label and the error label. The widget looks up everything else in its own translation table, keyed on navigator.languages, and the gateway never sets data-cap-lang. That covers the "Verifying..." text shown while the proof of work runs, which can take several seconds, and every screen-reader label ("Click to verify you're a human", "Verifying you're a human, please wait", "We have verified you're a human, you may now continue"). So those follow the browser's language, not the language the page was rendered in. A patient reading the page in French on an English-language phone hears and sees English mid-flow. An English page in a Spanish browser shows "Verificando...". In addition, Root.tsx hard-codes the notification shown when verification fails, along with two other notification strings in the same component, in English. That is against the rule that every user-facing string goes through t(). The unit test for CapWidget claims to "label every widget state, since the widget renders no text of its own", but the widget does render its own text for the states it does not override.
Where
apps/gateway/src/components/Cap.tsx:33-47. Only three data-cap-i18n-* attributes are set, and there is no data-cap-lang:
<cap-widget
data-cap-api-endpoint="/api/auth/"
data-cap-i18n-error-label={t({ en: 'Error', es: 'Error', fr: 'Erreur' })}
data-cap-i18n-initial-state={t({ en: "I'm a human", es: 'Soy una persona', fr: 'Je suis un humain' })}
data-cap-i18n-solved-label={label}
id="cap"
ref={ref}
/>@cap.js/widget@0.1.58 (src/cap.js:62-84, :849-853) resolves its own table from data-cap-lang or navigator.languages, once, when the element connects. Its defaults include verifying-label, verify-aria-label, verifying-aria-label, verified-aria-label, error-aria-label, required-label, troubleshooting-label and group-aria-label.
apps/gateway/src/Root.tsx:49, :55 and :71:
notifications.addNotification({ message: 'Verification failed, please try again', type: 'error' });
// ...
notifications.addNotification({ message: 'Please complete the verification challenge', type: 'error' });
// ...
notifications.addNotification({ message: 'Internal Server Error', type: 'error' });Reproduce
- In a browser whose language is English, open an assignment link with
?lang=fr. - Click the verification checkbox and watch its label while it solves. With a screen reader, focus the checkbox.
- Make
POST /api/auth/verifyfail, for example by restarting the gateway between solving and verifying, or by blocking the request in devtools.
Actual: step 2 shows "Verifying..." and announces "Click to verify you're a human" / "Verifying you're a human, please wait" in English on an otherwise French page. Step 3 shows "Verification failed, please try again" in English. With the browser in Spanish and the page in English, step 2 shows "Verificando..." instead.
Expected: every label and announcement in the verification step, and every notification Root raises, is in the page's current language, including after the patient switches language with the toggle.
Tests
apps/gateway/src/components/__tests__/Cap.test.tsx:it('should set every widget label in the page language, so the browser language never leaks into the verification step'). Assert eachdata-cap-i18n-*key, including the aria labels, underfr.apps/gateway/src/__tests__/Root.test.tsx:it('should report a failed verification in the patient's language').- e2e,
testing/src/specs/gateway-assignment.spec.ts: open the link with?lang=fr(Playwright's default locale isen-US) and assert that the widget's trigger is found bygetByRole('button', { name: 'Cliquez pour vérifier que vous êtes humain' }).
Suggested fix
In Cap.tsx, pass a translated data-cap-i18n-* attribute for every key the widget defines. Do not rely on data-cap-lang: the widget reads it only on connect, so a later language toggle would not update it. getI18nText reads the attributes on each use, so these follow the toggle. In Root.tsx, call useTranslation() in Assignment and wrap the three notification messages in t({ en, es, fr }).
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing has touchedCap.tsxorRoot.tsxsince this was filed, and 0.1.58 is still the latest@cap.js/widgeton npm.Two labels do not follow the toggle
Setting every attribute fixes the repro as written, where the language comes from
?lang=. It does not fully cover a later toggle. In@cap.js/widget@0.1.58:data-cap-i18n-initial-stateis the only i18n attribute inobservedAttributes(src/cap.js:855-865).group-aria-labelandverify-aria-labelare set once, increateUI()(:1598-1614), whichconnectedCallbackcalls (:961).If the patient toggles before clicking, the visible label changes, but the trigger (
role="button") and the group keep their accessible names in the language the page loaded in. Every other key is read when it is used, so it follows. Upstreammainhas the same code.Covering those two here costs something either way:
CapWidgetonresolvedLanguageinAssignment. A solved widget then redraws unsolved whileverifiedstays true, and a toggle mid-solve aborts the proof of work (disconnectedCallback,:1915). The key must go on the component, not on<cap-widget>: thesolvelistener is added in an effect with[]deps (Cap.tsx:25-30), so a replaced element would never reachonSolve.I'd land the attributes here, narrow Expected to exclude those two names after a toggle, and raise the gap upstream (observe both attributes and re-apply them).
Missed keys
wasm-disabled("Enable WASM for significantly faster solving",:1542-1545) goes throughgetI18nTextbut is not in the widget's table, so it is English in every browser. It shows whenWebAssemblyis unavailable, on the legacy challenge path that@cap.js/server2 (onmain) and 4.0.5 (build: upgrade dependencies held back by breaking changes #1660) both use. That makes 12 keys.aria-labelandtitle("Secured by Cap",:1633-1638) are literals with no attribute. Only upstream can change them.Make the keys a type: a closed union in
src/typings/cap.d.ts, with each`data-cap-i18n-${key}`prop required oncap-widget, so a missing label failstsc. Do not derive it from the package's exportedCapConfig: it lists 10 of the 12 and omitsgroup-aria-labelandtroubleshooting-label. Also,Cap.tsx:40-44repeats thet()already inlabel(:11-15). Uselabel.Root.tsx:71is not a server error, and the real renderer never reaches it:InstrumentRenderersends aSERIEStarget toSeriesInstrumentRenderer, which always submitskind: 'SERIES'. Translate a generic error there, not "Internal Server Error", and updateRoot.test.tsx:181with it.Tests
Cap.test.tsx:26with the issue's test. Calli18n.changeLanguage('fr'), loop over a table of the expected French strings, and reset toeninafterEach.Rootignores itslanguageprop (Root.tsx:95-96), so theRoot.test.tsxcase must also calli18n.changeLanguage('fr'). Passinglanguage="fr"changes nothing.seedAssignment(api), open${assignment.url}?lang=fr, and assert the trigger's name before clicking.admin-settings.spec.ts:61-69briefly turns French off, so retry thegotowithtoPassuntil the response is French, asgateway-landing.spec.ts:43-46does. Keep the Englishverify-aria-labelequal to the widget default, or updategateway-assignment.spec.ts:84.Land this before #1758. Its suggested fix renders
CapWidgetin a dialog and changes thehandleSolveandhandleSubmitpaths that raise the:49and:55notices.Triage note written with Claude Code. Code references point at
main@d86740e.