Web Forms 1.1.1: six issues (UI language fallback, title wrapping, guidance hints, region names, contrast, select_one_external)

1. What is the issue? Please be detailed.

We use Central v2026.3.1 and embed @getodk/web-forms 1.1.1 (the version in central-frontend v2026.3.1) in an internal page where our team previews forms before publishing them. While testing that page with Chromium and axe-core 4.13 we found six issues. All of them reproduce with the unmodified npm build in a minimal host page, and the code involved in 1.1 to 1.5 looks unchanged on central-frontend master today. Two of them are related to open issues (getodk/web-forms#466 and #53).

1.1 A regional browser language loses to any later exact match, so the UI language is often wrong

With a form that declares no languages:

navigator.languages Submit button Expected
['es'] or ['es-PA', 'es'] Enviar Enviar
['es-PA'] or ['es-419'] Send Enviar
['fr-CA'] Send Envoyer
['en-US', 'es'] Enviar Send
['es-PA', 'fr'] Envoyer Enviar

Forms whose languages have no code (e.g. label::Español) behave the same; with a code (label::Español (es)) the UI follows the form language and is correct.

findBestLocale() tries exact matches for all candidates before it tries any base language, and resolveUILocaleCandidates() appends en to the browser languages. So for ['es-PA'] the candidates are ['es-PA', 'en'], and en matches exactly before the base language es is tried (packages/web-forms/src/lib/locale/useLocale.ts). PrimeVue's locale is chosen with the same function and candidates. Trying each candidate's base language right after the candidate itself fixes every row above: we checked it by patching the 1.1.1 build.

1.2 The form title breaks in the middle of words on narrow screens

At 360 px wide, the title "Household survey on drinking water and sanitation" is shown as "Household survey on drin" / "king water and sanitation". The title h1 has word-break: break-all (FormHeader.vue), added to fix long titles without spaces (getodk/web-forms#481). With break-all, any title that needs more than one line is cut at the last character that fits, usually in the middle of a word. With word-break: normal; overflow-wrap: anywhere instead, the title wraps at spaces, and a long title without spaces still wraps without horizontal scrolling (we checked both at 360 px).

1.3 guidance_hint is appended to the hint and always shown

A question with the hint "This is the hint." and the guidance_hint "This is the guidance hint." shows a single hint: "This is the hint.This is the guidance hint." (with no space between them). A question with only a guidance_hint shows it as a normal hint. The feature matrix doesn't mark guidance hints as supported (the row links to getodk/web-forms#53), so we expected them to be hidden, as Collect does by default. In packages/xforms-engine/src/lib/reactivity/text/createTextRange.ts, generateChunksForTranslation() adds every <value> of the itext entry as text unless its form is a media type, so <value form="guidance"> ends up in the hint. Skipping form="guidance" values until #53 is done would match Collect's default.

1.4 Group and repeat regions all have the same, empty name

axe reports landmark-unique (moderate, a best-practice rule) on our form with two labelled groups and on our form with three repeat instances; from the code, it should happen whenever two or more group or repeat panels are shown. The content of each panel is a PrimeVue Panel region (role="region") whose aria-labelledby points to the panel's toggle button. That button has no text and no aria-label (Web Forms uses its own clickable header and hides the button in FormPanel.vue), so every region's accessible name is empty. Passing the panel title to PrimeVue as toggleButtonProps.ariaLabel (or as the header prop) should be enough, e.g. "Household member 2" for a repeat instance.

1.5 The Send button doesn't meet WCAG AA contrast (getodk/web-forms#466)

White text on the primary color #3e9fcc is 2.98:1; AA needs 4.5:1, and axe reports color-contrast (serious). #466 proposes #007DB8 (4.54:1); odk-theme-preset.ts on master still has #3e9fcc. At desktop widths (we saw it at 1024 and 1280 px, not at 360 or 800 px) axe also flags the "Powered by" and version text in the footer (#64748b on #f1f5f9, 4.34:1).

1.6 select_one_external shows a free-text box

The <input query="..."> that pyxform generates for select_one_external is rendered as a plain text input, with no warning on screen or in the console, and it accepts any text. We know select_one_external isn't in the Web Forms feature matrix, but showing that the question isn't supported, instead of a text box, would avoid collecting free text by mistake.

2. What steps can we take to reproduce this issue?

  1. In a blank page, install webFormsPlugin and mount OdkWebForm from @getodk/web-forms@1.1.1 (npm) with Vue, passing formXml, fetchFormAttachment and an onSubmit handler, with no theme or locale overrides.
  2. Convert the XLSForms below with pyxform 4.5.0 and open them in Chromium.
  3. For 1.1, set the browser languages (for example, Playwright's locale: 'es-PA'), open form A and read the submit button.
  4. For 1.2, open form A at 360 px wide.
  5. For 1.3 and 1.6, open forms B and D (in D, type anything in District).
  6. For 1.4 and 1.5, run axe-core 4.13 (WCAG 2.2 AA and best practices) on forms C, E and A at 1280 px wide.

Form A (1.1, 1.2, 1.5), with form_title "Household survey on drinking water and sanitation" in settings:

type name label
text respondent What is your name?
select_one yes_no consent Do you agree to take part?

(choices: yes_no with yes/Yes and no/No)

Form B (1.3):

type name label hint guidance_hint
text q1 Question with hint and guidance_hint This is the hint. This is the guidance hint.
text q2 Question with guidance_hint only Guidance only.

Form C (1.4):

type name label repeat_count
begin_repeat member Household member 3
text member_name Name
end_repeat

Form D (1.6), with province in choices and district (with a province column) in external_choices:

type name label choice_filter
select_one province province Province
select_one_external district district District province=${province}

Form E (1.4):

type name label
begin_group g1 Section A
text a A
end_group
begin_group g2 Section B
text b B
end_group

3. What have you tried to fix the issue?

In our page we work around 1.1 by putting es first in navigator.languages before mounting, and 1.5 by setting --p-primary-color, --p-primary-hover-color and --p-primary-active-color to the 700, 800 and 900 shades, and --odk-primary-text-color to the 700 shade (about 5.4:1 for the button). For the other four we only warn our users. We're happy to test fixes or share the test page.

4. Upload any forms or screenshots you can share publicly below.

The forms are in the tables above. We can attach the XLSForm files and screenshots if that helps.

Hi @mercaint ! When you have a chance consider introducing yourself here so we can learn more about how you're using ODK and Web Forms!

Thank you for your incredibly detailed investigation. I've managed to reproduce those problems and raised issues for them...

1.1: New issue raised

1.2: New issue raised

1.3: I think this is covered by this comment . I'm not sure that hiding the guidance hint is correct, and maybe it's better to show it all the time, but with a line break, so it's separate from the hint. Or possibly we should just prioritise implementing guidance hints properly. I'll raise this for discussion.

1.4: New issue raised

1.5: I'll look at prioritising the issue you linked. Thanks.

1.6: I think this is the same as this issue. You're right that Web Forms should not be showing this as a freetext input.

Thanks a lot, Gareth, for reproducing all six so quickly, and for already fixing the title wrapping!

One note on 1.6: #928 is about Collect’s external-app fields (the ex: appearance and the intent attribute). select_one_external is a different case: pyxform writes it as an with a query attribute that refers to the external choices, with no intent or ex: appearance. So a fix that only looks at external apps might not catch it; it may be worth checking for query too.

Warm regards.

Oscar

Ahh thanks for clarifying. You're right that's totally different. I'll figure out why it's missing and get back to you!

I've raised a new issue to track 1.6.