Found while writing the #385 Playwright E2E ReadOnly persona spec — a deep-link refusal is announced on one screen and silent on another.
What differs
Both screens are reachable by URL for any authenticated user (there is no route-level gate in the SPA by design — the server is the boundary), and both refuse a ReadOnly user with a 403 that they render as an error paragraph. Only one of them announces it:
| Screen |
Load-failure markup |
AuditPage.tsx |
<p className="error" role="alert">{error}</p> |
UsersPage.tsx (error && users === null branch) |
<p className="error">{error}</p> — no role |
ReportsPage, ExportPage, StockPage and HistoryPage all use role="alert" for the same situation, so UsersPage is the outlier rather than AuditPage being unusually careful.
Why it matters
role="alert" is an implicit live region. Without it, a screen-reader user who follows a link to /users and is refused hears the heading and then nothing — the page appears to have simply loaded. The visual user sees red text; the assistive-technology user gets silence and no reason to look further.
This is the same class of gap the SPA otherwise takes care over: the app ships a skip link, aria-labelled nav landmarks, role="status" on BusyButton's working announcement and on the daily-entry grading readout, and aria-live="polite" on the PWA update banner. One un-announced refusal stands out against that.
Suggested fix
Add role="alert" to the load-failure paragraph in UsersPage.tsx:347, matching the sibling screens. Worth a sweep at the same time: UsersPage has several other bare <p className="error">{error}</p> sites (dialog-level validation errors around lines 399, 409, 484, 501) — a dialog error that is never announced has the same problem in a tighter loop, since the user is actively waiting for the result of a submit.
Not necessarily one rule for all of them: a dialog error adjacent to the control that caused it may be better served by aria-describedby on the field than by an alert. The load-failure case is the unambiguous one.
What #385 does in the meantime
The ReadOnly deep-link spec asserts the app's own p.error element rather than role="alert", so it tests the authorization boundary it is actually about and does not fail for an accessibility reason. That choice is commented in the spec with a pointer here. If this lands, the spec can tighten to getByRole("alert") for both routes.
Found while writing the #385 Playwright E2E ReadOnly persona spec — a deep-link refusal is announced on one screen and silent on another.
What differs
Both screens are reachable by URL for any authenticated user (there is no route-level gate in the SPA by design — the server is the boundary), and both refuse a ReadOnly user with a 403 that they render as an error paragraph. Only one of them announces it:
AuditPage.tsx<p className="error" role="alert">{error}</p>UsersPage.tsx(error && users === nullbranch)<p className="error">{error}</p>— no roleReportsPage,ExportPage,StockPageandHistoryPageall userole="alert"for the same situation, soUsersPageis the outlier rather thanAuditPagebeing unusually careful.Why it matters
role="alert"is an implicit live region. Without it, a screen-reader user who follows a link to/usersand is refused hears the heading and then nothing — the page appears to have simply loaded. The visual user sees red text; the assistive-technology user gets silence and no reason to look further.This is the same class of gap the SPA otherwise takes care over: the app ships a skip link,
aria-labelled nav landmarks,role="status"onBusyButton's working announcement and on the daily-entry grading readout, andaria-live="polite"on the PWA update banner. One un-announced refusal stands out against that.Suggested fix
Add
role="alert"to the load-failure paragraph inUsersPage.tsx:347, matching the sibling screens. Worth a sweep at the same time:UsersPagehas several other bare<p className="error">{error}</p>sites (dialog-level validation errors around lines 399, 409, 484, 501) — a dialog error that is never announced has the same problem in a tighter loop, since the user is actively waiting for the result of a submit.Not necessarily one rule for all of them: a dialog error adjacent to the control that caused it may be better served by
aria-describedbyon the field than by an alert. The load-failure case is the unambiguous one.What #385 does in the meantime
The ReadOnly deep-link spec asserts the app's own
p.errorelement rather thanrole="alert", so it tests the authorization boundary it is actually about and does not fail for an accessibility reason. That choice is commented in the spec with a pointer here. If this lands, the spec can tighten togetByRole("alert")for both routes.