Two accessibility improvements deliberately left out of the a11y PR because each one breaks Playwright selectors, and one of them changes product behaviour. Splitting them out so they can be reviewed on their own terms.
1. AUTO/MANUAL should be a radio group
components/organisms/journey-form.tsx renders the pair as two buttons with aria-pressed. Assistive tech announces them as two independent toggles: the mutual exclusion is invisible, they are two separate tab stops, and there is no arrow-key navigation between them.
Cheapest correct fix is two visually-hidden <input type="radio" name="exit-mode"> with styled <label>s — the platform then supplies grouping, aria-checked, arrow keys and the roving tab stop for zero JS. (role="radiogroup" plus hand-written key handling reaches the same result with more code to maintain.)
Cost:tests/e2e/work-calculator.spec.ts uses page.click('button:has-text("MANUAL")'), which stops matching once it is no longer a <button>. Becomes page.getByRole("radio", { name: "MANUAL" }).check().
2. The Jornada / Custo-da-Hora nav should not be aria-pressed buttons
app/page.tsx wraps them in <nav> and marks them with aria-pressed. They swap the main panel, so they are tabs, not navigation.
Two options, and this is the decision to make:
(a) role="tablist" — role="tab" + aria-selected + aria-controls on the buttons, role="tabpanel" on the panel, drop the <nav>. Keeps them as <button>, so no e2e selector changes.
(b) Real links with the view in the URL — <Link href="/?view=work"> plus aria-current="page", reading view via useSearchParams. This additionally fixes two things (a) does not: the tab becomes shareable and bookmarkable (activeView is currently unshareable useState), and Cmd-click / middle-click start working because they would be real anchors.
Cost of (b): breaks both spec files — salary-calculator.spec.ts and work-calculator.spec.ts both use button:has-text(...). They become page.getByRole("link", { name: "Jornada" }).click().
My recommendation is (b): the URL-state win is worth more than the selector churn, and the selector change is mechanical.
3. Minor items from the same audit, unblocked and cheap
salary-calculator.tsx, tax-details-panel.tsx, extra-entry-row.tsx — currency fields set value={formatCurrencySimple(...)}, reformatting on every keystroke. When React's written value differs from what was typed, the browser resets the caret to the end: appending feels fine, editing mid-string throws the caret to the end each character. components/atoms/masked-input.tsx already solves this correctly for date and time with local state committed on completion — route currency through the same pattern, or format on blur only.lib/utils.ts — formatCurrency and formatCurrencySimple construct a fresh Intl.NumberFormat on every call, i.e. once per keystroke on a controlled input. Hoist both to module scope.journey-form.tsx — "Resetar Horários" wipes every entered time with no confirmation and no undo.- Inputs lack
name, autoComplete="off", enterKeyHint and spellCheck={false}. Cheapest is defaults in components/atoms/input.tsx. components/molecules/side-ads.tsx — the ads render from 2xl (>=1536px) while the content container is 2xl:max-w-[1600px]. At 1536px the ads sit at x=16..176 and x=1360..1520 while content spans 32..1504, so they overlap. Gate at min-[1980px].app/page.tsx — pt-32 pb-32 is 256px of fixed vertical padding; at 375x667 in landscape only 200px of 375px is usable. Use pt-24 pb-28 sm:pt-32 sm:pb-32.- No
env(safe-area-inset-bottom) on the bottom-anchored nav, cookie banner or privacy button; bottom-8 is 32px against the 34px iOS home indicator. app/layout.tsx — no <link rel="preconnect"> for pagead2.googlesyndication.com or googletagmanager.com, both fetched at runtime.- Times are formatted with a hardcoded
format(d, "HH:mm:ss"); Intl.DateTimeFormat("pt-BR", { timeStyle: "medium" }) respects the user's locale.
Found while auditing the UI against the Web Interface Guidelines for #6 and #7. Contrast ratios, element sizes and overlap coordinates in the linked PR were measured against the built CSS and a real browser, not estimated.
Two accessibility improvements deliberately left out of the a11y PR because each one breaks Playwright selectors, and one of them changes product behaviour. Splitting them out so they can be reviewed on their own terms.
1. AUTO/MANUAL should be a radio group
components/organisms/journey-form.tsxrenders the pair as two buttons witharia-pressed. Assistive tech announces them as two independent toggles: the mutual exclusion is invisible, they are two separate tab stops, and there is no arrow-key navigation between them.Cheapest correct fix is two visually-hidden
<input type="radio" name="exit-mode">with styled<label>s — the platform then supplies grouping,aria-checked, arrow keys and the roving tab stop for zero JS. (role="radiogroup"plus hand-written key handling reaches the same result with more code to maintain.)Cost:
tests/e2e/work-calculator.spec.tsusespage.click('button:has-text("MANUAL")'), which stops matching once it is no longer a<button>. Becomespage.getByRole("radio", { name: "MANUAL" }).check().2. The Jornada / Custo-da-Hora nav should not be
aria-pressedbuttonsapp/page.tsxwraps them in<nav>and marks them witharia-pressed. They swap the main panel, so they are tabs, not navigation.Two options, and this is the decision to make:
(a)
role="tablist"—role="tab"+aria-selected+aria-controlson the buttons,role="tabpanel"on the panel, drop the<nav>. Keeps them as<button>, so no e2e selector changes.(b) Real links with the view in the URL —
<Link href="/?view=work">plusaria-current="page", readingviewviauseSearchParams. This additionally fixes two things (a) does not: the tab becomes shareable and bookmarkable (activeViewis currently unshareableuseState), and Cmd-click / middle-click start working because they would be real anchors.Cost of (b): breaks both spec files —
salary-calculator.spec.tsandwork-calculator.spec.tsboth usebutton:has-text(...). They becomepage.getByRole("link", { name: "Jornada" }).click().My recommendation is (b): the URL-state win is worth more than the selector churn, and the selector change is mechanical.
3. Minor items from the same audit, unblocked and cheap
salary-calculator.tsx,tax-details-panel.tsx,extra-entry-row.tsx— currency fields setvalue={formatCurrencySimple(...)}, reformatting on every keystroke. When React's written value differs from what was typed, the browser resets the caret to the end: appending feels fine, editing mid-string throws the caret to the end each character.components/atoms/masked-input.tsxalready solves this correctly for date and time with local state committed on completion — route currency through the same pattern, or format onbluronly.lib/utils.ts—formatCurrencyandformatCurrencySimpleconstruct a freshIntl.NumberFormaton every call, i.e. once per keystroke on a controlled input. Hoist both to module scope.journey-form.tsx— "Resetar Horários" wipes every entered time with no confirmation and no undo.name,autoComplete="off",enterKeyHintandspellCheck={false}. Cheapest is defaults incomponents/atoms/input.tsx.components/molecules/side-ads.tsx— the ads render from2xl(>=1536px) while the content container is2xl:max-w-[1600px]. At 1536px the ads sit at x=16..176 and x=1360..1520 while content spans 32..1504, so they overlap. Gate atmin-[1980px].app/page.tsx—pt-32 pb-32is 256px of fixed vertical padding; at 375x667 in landscape only 200px of 375px is usable. Usept-24 pb-28 sm:pt-32 sm:pb-32.env(safe-area-inset-bottom)on the bottom-anchored nav, cookie banner or privacy button;bottom-8is 32px against the 34px iOS home indicator.app/layout.tsx— no<link rel="preconnect">forpagead2.googlesyndication.comorgoogletagmanager.com, both fetched at runtime.format(d, "HH:mm:ss");Intl.DateTimeFormat("pt-BR", { timeStyle: "medium" })respects the user's locale.Found while auditing the UI against the Web Interface Guidelines for #6 and #7. Contrast ratios, element sizes and overlap coordinates in the linked PR were measured against the built CSS and a real browser, not estimated.