Skip to content

Gateway verification step speaks the browser's language, not the page's, and its failure notice is hard-coded English #1715

Open

Description

@joshunrau

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

  1. In a browser whose language is English, open an assignment link with ?lang=fr.
  2. Click the verification checkbox and watch its label while it solves. With a screen reader, focus the checkbox.
  3. Make POST /api/auth/verify fail, 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 each data-cap-i18n-* key, including the aria labels, under fr.
  • 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 is en-US) and assert that the widget's trigger is found by getByRole('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

  1. added
    BugType: existing behavior is wrong
    Priority: LowNice to have, cosmetic or speculative
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Gatewayapps/gateway (patient-facing app for remote assignments)
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Nothing has touched Cap.tsx or Root.tsx since this was filed, and 0.1.58 is still the latest @cap.js/widget on 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-state is the only i18n attribute in observedAttributes (src/cap.js:855-865).
    • group-aria-label and verify-aria-label are set once, in createUI() (:1598-1614), which connectedCallback calls (: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. Upstream main has the same code.

    Covering those two here costs something either way:

    • Key CapWidget on resolvedLanguage in Assignment. A solved widget then redraws unsolved while verified stays true, and a toggle mid-solve aborts the proof of work (disconnectedCallback, :1915). The key must go on the component, not on <cap-widget>: the solve listener is added in an effect with [] deps (Cap.tsx:25-30), so a replaced element would never reach onSolve.
    • Set the two labels again through the open shadow root. That depends on the widget's private markup, and the trigger's label may only be replaced before the click, since solving overwrites it.

    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 through getI18nText but is not in the widget's table, so it is English in every browser. It shows when WebAssembly is unavailable, on the legacy challenge path that @cap.js/server 2 (on main) and 4.0.5 (build: upgrade dependencies held back by breaking changes #1660) both use. That makes 12 keys.
    • The credits link's aria-label and title ("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 on cap-widget, so a missing label fails tsc. Do not derive it from the package's exported CapConfig: it lists 10 of the 12 and omits group-aria-label and troubleshooting-label. Also, Cap.tsx:40-44 repeats the t() already in label (:11-15). Use label.

    Root.tsx:71 is not a server error, and the real renderer never reaches it: InstrumentRenderer sends a SERIES target to SeriesInstrumentRenderer, which always submits kind: 'SERIES'. Translate a generic error there, not "Internal Server Error", and update Root.test.tsx:181 with it.

    Tests

    • Replace Cap.test.tsx:26 with the issue's test. Call i18n.changeLanguage('fr'), loop over a table of the expected French strings, and reset to en in afterEach.
    • Root ignores its language prop (Root.tsx:95-96), so the Root.test.tsx case must also call i18n.changeLanguage('fr'). Passing language="fr" changes nothing.
    • e2e: the French check needs no proof of work. Use seedAssignment(api), open ${assignment.url}?lang=fr, and assert the trigger's name before clicking. admin-settings.spec.ts:61-69 briefly turns French off, so retry the goto with toPass until the response is French, as gateway-landing.spec.ts:43-46 does. Keep the English verify-aria-label equal to the widget default, or update gateway-assignment.spec.ts:84.

    Land this before #1758. Its suggested fix renders CapWidget in a dialog and changes the handleSolve and handleSubmit paths that raise the :49 and :55 notices.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Gatewayapps/gateway (patient-facing app for remote assignments)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPriority: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions