fix(a11y): accessibility fixes across the ui, and an accessible show password button - #137
Merged
Conversation
Findings from the RGAA re-audit of a customer portal, on the two pages
served by simple-directory inside it.
Login page:
- the logo alternative was the generic "Site logo" i18n string, it now
uses the site title when there is one (RGAA 1.7)
- the served document had no <title>, only the one the SPA sets at
runtime, so the W3C validator rejected it: the built index.html now
carries a {SITE_TITLE} placeholder filled by the site title (RGAA 8.2)
- vite injects <meta property="csp-nonce" nonce="…"> for html.cspNonce
without the content attribute the HTML spec requires next to property,
which is the second error the validator reported (RGAA 8.2)
Account page:
- the email address was displayed in a disabled field, dimmed to 38%
opacity, which drops its contrast well below 4.5:1. It is now readonly,
which keeps it readable and focusable, with a hint saying it cannot be
changed (RGAA 3.2)
- the avatar image had no alt attribute (RGAA 1.1)
The page keeps starting its titles at h2: it is always embedded in an
iframe, the h1 belongs to the host page.
- the four menus opened by a v-fab (create a department, edit the departments, invite a partner organization) had no accessible name: v-fab renders its title on the wrapping div, not on the button it contains. They go back to v-btn, like the invite member button right next to them, which was already correct (RGAA 7.1) - the bell button of the notification menu had no name at all, it gets a title (RGAA 7.1) - the organization, department, member and partner avatars were v-img without alt, so img elements with no alt attribute at all (RGAA 1.1)
- the 2FA roles select and the members role filter were fed the raw role keys (admin, contrib, user) while the readable labels sit right next to them in rolesLabels, which the same page even lets you edit. Both now build their items from those labels, with the configured defaults as fallback, and the role shown under each member follows - the Vuetify 4 upgrade replaced the removed default uppercase on buttons with text-transform: capitalize, which capitalizes every word: "Renouveler Le Mot De Passe". The rule is dropped, buttons render their label as written, and the four labels that were written in lowercase because they relied on it are capitalized
BatLeDev
marked this pull request as draft
August 13, 2026 15:45
Carries the boundIp session field that lib-express 1.25.0 reads, without which tsc rejects the assignment in setSessionCookies.
- Account page email input was changed from disabled to readonly for accessibility (RGAA 3.2 contrast); assert readonly/non-editable instead of disabled. - Raise navigationTimeout to 15s for the e2e project: full page loads (incl. superadmin login) can exceed 5s when the whole suite loads the server, causing flaky failures.
BatLeDev
marked this pull request as ready for review
August 14, 2026 08:57
The avatar upload field used a single label ("chargez un nouvel avatar")
in the 4 places load-avatar is mounted, so users editing an organization
or a department thought they were replacing their own avatar. The label
is now computed from the owner: user, organization, or department (with
the organization's configurable department label).
The label is also the accessible name of the file input, so a generic
one left screen reader users with no way to tell the two apart either.
While at it, imperative labels are turned into infinitives to match the
convention already used by the vast majority of controls, in every
locale: dialog titles, links, and mail buttons. Prose addressed to the
user keeps the imperative. A few missing hyphens in French inverted
forms are fixed too, and the German department actions get the right
word order.
Two labels rendered a placeholder literally: the Spanish user email
dialog translated the placeholder itself ({nombre} instead of {name}),
and the German member deletion message used {name} while the caller
passes {org}.
deleteMyselfAlert and deleteMyselfCheck had lost their placeholders in
de/es/it/pt, so the account deletion confirmation named neither the
cancellation delay nor the account being deleted. The Italian
deleteMyselfCheck was in Spanish.
The rest is spelling and agreement, mostly French: "Modifer",
"Propriéraire", "identitié", "aucun role", "boite" -> "boîte",
"chaine" -> "chaîne", "le champs", "débloquage", "vis à vis",
"une invitation a été envoyé", "que vous avez reçu", "un bref interval",
"vous même", a duplicated period, and a stray quote at the start of a
config description. In English, "Your are not a member in any
organization" and "If your delete your account".
editUserEmailTitle was also still an imperative in fr/de/it/pt, unlike
the two sibling titles of the same screen.
…aceholder
The site title reaches the served HTML through getSiteExtraParams, which
microTemplate applies before the CSP_NONCE pass. A title holding the literal
{CSP_NONCE} therefore came back with the request nonce inside <title>. And
microTemplate interpolates via String.replace, so $&, $` and $' in the title
were read as replacement patterns.
Neutralize '{' and double '$' alongside the existing HTML escaping.
The inject-site-context plugin mirrors the express middleware in dev, but was
missing the new SITE_TITLE param, so the browser tab showed the raw
{SITE_TITLE} placeholder.
title is not a VImg prop, so it landed on the wrapper div and left the 2FA qr code <img> without any alternative. Move it to alt, which VImg forwards to the img element. The site logo next to the host name is decorative, mark it as such.
…mpty title is optional on the site schema and has no minLength, so a site saved with an empty title produced <title></title>. That fails the very criterion the placeholder was added for. ?? only guards undefined and null, use || instead.
An icon-only button has no text content, so its accessible name came from the title attribute alone. That is only the last resort of the accessible name computation and RGAA auditors flag it, while the tooltip title carries remains useful to sighted users. Keep both: mirror each title into an aria-label. Five buttons on the admin pages had neither, so no accessible name at all: editing a user email, dropping a user 2FA, editing the created orgs quota and opening an organization from the two organization tables. Name them from the labels their own dialogs already use, plus a new common.seeTitle.
Removing the global .v-btn text-transform rule left the main action buttons inconsistent: four of them carried an inline text-transform: uppercase and the six others none at all, so on the login page a single button stood out in capitals. Uppercase every bottom right action button and go through the text-uppercase utility class, which drops the inline styles and leaves no text-transform declaration of our own in the ui.
…field The login password field had no way to reveal what was typed, and the two new password fields relied on a bare v-icon with a click handler: an <i> element, so not focusable, not operable with a keyboard, with no accessible name and no state restitution. The two confirmation fields had nothing at all, and shared their reveal state with the field above. Introduce password-reveal and put it in the append-inner slot of the five password fields. It renders a real button, carries a title and a matching aria-label that follow the state, and states the new state in a screen reader only live region without ever exposing the password itself. Deliberately no re-masking after a delay: an unrequested time limit works against RGAA 13.1 and five seconds is not enough to re-read a long password. Neither on blur, which would fire when reaching for the button. The password is re-masked when the form is submitted, as the GOV.UK component does. Also give the three fields that all carried id="password" a unique id, they are in the DOM at the same time inside the window items.
The audit of the six locale files started from an aria-label I mirrored from a
title holding a raw french string, and turned up more than that one.
api/i18n/pt.js carried the french htmlCaption of one of the login mails, while
the very same sentence sits translated a few lines above it in the same file:
portuguese users received a bilingual mail.
The templates held french literals that never went through i18n at all. The
contact page, which any user can reach, had its three field labels, its send
button and its success message written in french, and its heading pointed at
common.createOrganization, so it announced "create an organization". The three
invitation menus labelled their redirect select in french while common
.redirectSite was already translated right there. The password lists page had
its two help paragraphs, its file input, its list subtitles and its delete
confirmation in french, plus a $t('charger') that resolved to nothing.
Every string now goes through a key present in the six locales: 394 keys, no
gap in any direction.
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.
Accessibility fixes on the pages served by simple-directory, coming from the RGAA re-audit of a customer portal, plus two regressions of the Vuetify 4 upgrade found along the way.
Also closes the "show the password behind an eye icon" ticket, which turned out to belong here.
Login page
Site logoi18n string, it now uses the site title when there is one (RGAA 1.7)<title>, only the one the SPA sets at runtime, so the W3C validator rejected it. The builtindex.htmlnow carries a{SITE_TITLE}placeholder filled by the site title, HTML-escaped before injection.titleis optional on the site schema, so an empty one falls back to the default rather than producing<title></title><meta property="csp-nonce" nonce="…">forhtml.cspNoncewithout thecontentattribute the HTML spec requires next toproperty, the second error the validator reported. A smalltransformIndexHtmlplugin adds it (RGAA 8.2)id="password"get a unique id, they sit in the DOM at the same time inside the window itemsShowing the password
The login password field had no way to reveal what was typed, and the two new password fields relied on a bare
v-iconwith a click handler: an<i>element, so not focusable, not operable with a keyboard, with no accessible name and no state restitution. The two confirmation fields had nothing at all and shared their reveal state with the field above them.A new
password-revealcomponent now sits in theappend-innerslot of the five password fields. It renders a real<button type="button">, carries atitleand a matchingaria-labelthat follow the state, and states the new state in a screen reader only live region without ever exposing the password itself.Deliberately no re-masking after a delay, which the ticket suggested. RGAA has no criterion on revealing a password, and the only one that speaks about delays is 13.1, "l'utilisateur a-t-il le contrôle de chaque limite de temps modifiant le contenu" (WCAG 2.2.1 / 2.2.2) — an unrequested timer works against it, and five seconds is not enough to re-read a long password. No re-masking on blur either, since blur fires when reaching for the button. The password is re-masked when the form is submitted, which is what the GOV.UK component does, and the DSFR password component has no timer either.
Account page
The page keeps starting its titles at h2: it is always embedded in an iframe, the h1 belongs to the host page.
Organization page
v-fab(create a department, edit the departments, invite a partner organization) had no accessible name:v-fabrenders a<div>root and only forwards the propsv-btndeclares, sotitleand the menu activator'sv-bind="props"(aria-haspopup,aria-expanded,id, the click handler) all landed on that wrapper. The button was neither named nor keyboard operable. They go back tov-btn, like the invite member button right next to them, which was already correct (RGAA 7.1, 7.3)v-imgwithout alt, soimgelements with no alt attribute at all (RGAA 1.1)Naming every icon-only button
An icon-only button has no text content, so its accessible name came from the
titleattribute alone. That is only the last resort of the accessible name computation and auditors flag it, while the tooltip stays useful to sighted users, so both are kept: 35 buttons get anaria-labelmirroring their title.Five buttons on the admin pages had neither, so no accessible name at all: editing a user email, dropping a user 2FA, editing the created orgs quota, and opening an organization from the two organization tables. They are named from the labels their own dialogs already use, plus a new
common.seeTitle.Translations
Reviewing the
aria-labelabove surfaced one that mirrored atitleholding a raw french string, so the six locale files and every template literal went through an audit.The locale files themselves had no gap, but they did hold one wrong value:
api/i18n/pt.jscarried the frenchhtmlCaptionof one of the login mails, while the very same sentence sits translated fourteen lines above it in that same file. Portuguese users received a bilingual mail. Found by diffing long values that stayed identical to the french ones.The templates held literals that never went through i18n at all:
common.createOrganization, so the contact page announced "create an organization"common.redirectSitewas already translated in the six locales right there, unused$t('charger')that is not a key path and resolved to nothingSe connecter sur le siteon the login buttonEvery one of them now goes through a key present in the six locales. The tree is checked by an audit that asserts no key is missing in any direction, no template literal is left in a
title/label/aria-label/placeholder/subtitle, no$t()call is missing its key path, and no long value stayed identical to the french one: 394 keys, six locales, no gap.Vuetify 4 upgrade leftovers
admin,contrib,user) while the readable labels sit right next to them inrolesLabels, which the same page even lets you edit. Both now build their items from those labels, with the configured defaults as fallback, and the role shown under each member followstext-transform: capitalize, which capitalizes every word: "Renouveler Le Mot De Passe". The rule is dropped and buttons render their label as written. That left the main action buttons inconsistent, four carrying an inlinetext-transform: uppercaseand six none at all, so on the login page a single button stood out in capitals. Every bottom right action button is uppercase again, through thetext-uppercaseutility class: no inline styles and notext-transformdeclaration of our own left in the uiSecurity
The site title reaches the served HTML through
getSiteExtraParams, whichmicroTemplateapplies before theCSP_NONCEpass, so a title holding the literal{CSP_NONCE}came back with the request nonce inside<title>. AndmicroTemplateinterpolates viaString.replace, so$&,$`and$'in a title were read as replacement patterns.{is neutralized and$doubled alongside the existing HTML escaping.