Skip to content

Fix three WCAG 2.1 failures in the generated page - #2060

Open
oluies wants to merge 2 commits into
observablehq:mainfrom
oluies:accessibility-of-the-generated-page
Open

oluies wants to merge 2 commits into
observablehq:mainfrom
oluies:accessibility-of-the-generated-page

Conversation

@oluies

@oluies oluies commented Oct 26, 2026 •

Copy link
Copy Markdown

Three of the four defects in #2059 . The fourth, the missing lang on <html>, is a config question that overlaps #865 and #2054, so it is not here.

1.4.4 Resize text — drop maximum-scale=1

One line in src/render.ts. The directive stops a reader pinching to zoom; iOS has ignored it
for tap-zoom purposes since iOS 10, so what remains of it is the failure.

1.3.1 Info and relationships — the sidebar's close button is wrapped in an <li>

Only <li>, <script> and <template> may be children of <ol>, and the close <label> was a
direct child.

Moving it out of the list is the obvious fix and is wrong: #observablehq-sidebar-close is
position: absolute, the list is its containing block, and the first list is position: sticky.
Out of the list, the button's containing block becomes the scrolling <nav> and it scrolls away
with the content. So it is wrapped in <li class="observablehq-sidebar-close-item" aria-hidden="true"> with display: contents, which generates no box — the containing block
stays the list and nothing moves.

aria-hidden rather than role="presentation": axe-core's list rule still fails an
<li role="presentation"> as a child of <ol> ("List element has direct children that are not
allowed: [role=presentation]"), so that version did not fix the failure. aria-hidden takes the
wrapper out of the accessibility tree, which axe skips, and the list stays at one item for
assistive technology. Nothing is lost by hiding it: the close label is empty and mouse-only, and
keyboard and screen-reader users have the #observablehq-sidebar-toggle checkbox. The label is not
focusable, so aria-hidden-focus does not apply.

4.1.2 Name, role, value — aria-label on the search field

The placeholder stays; a placeholder is not an accessible name, since it disappears on the first
keystroke and several screen readers never announce it.

Snapshots

61 test snapshots change: 61 for the viewport line, 42 for the sidebar list, 3 for the search
input. They are mechanical substitutions of exactly those three strings.

Tests

yarn test:mocha in a node:20 container, each from a clean checkout with a fresh yarn install, on this branch and on 85e843e for comparison:

passing pending failing
85e843e 838 5 2
6dd84a9 (this branch) 838 5 2

The same two failures on both, and neither is mine: test/input/build/archives.posix wants a
zip the container does not have, and path-test.js:160 fails there too before any change.
test:lint, test:prettier and test:tsc all pass on this branch.

How it was found

An audit of a public site built with Framework 1.13.4, with axe-core 4.10.2 against wcag2a,
wcag2aa, wcag21a and wcag21aa. The site is documentation generated from the European
Banking Authority's data point model, for people who have to fill the templates in.

Found auditing a public site built with Framework 1.13.4, with axe-core against wcag2a,
wcag2aa, wcag21a and wcag21aa. None of the three is reachable from observablehq.config.js:
`head` is inserted after the elements, and nothing in the config removes a meta the
renderer writes.

1.4.4 Resize text: `maximum-scale=1` stops a reader pinching to zoom, on exactly the
devices where magnifying matters most. iOS has ignored it for tap-zoom since iOS 10, so
what is left of it is the failure.

1.3.1 Info and relationships: the sidebar's close button was a <label> directly inside the
<ol>, where only <li>, <script> and <template> are allowed. Moving it out of the list is
the obvious fix and is wrong - the button is `position: absolute` and the first list is
`position: sticky`, so out of the list its containing block becomes the scrolling <nav>
and it scrolls away with the content. It is wrapped in an <li> with `display: contents`
instead: no box is generated, the list stays the containing block, and nothing moves.
`role="presentation"` keeps the list at one item for assistive technology.

4.1.2 Name, role, value: the search field had a placeholder and no accessible name. A
placeholder is not one - it disappears on the first keystroke, and several screen readers
never announce it. The placeholder stays.

63 snapshots change with them: 61 for the viewport, 42 for the sidebar, 3 for the search
field.
@oluies
oluies marked this pull request as ready for review October 26, 2026 23:12
axe-core's list rule still fails an <li role="presentation"> as a child of <ol>
("List element has direct children that are not allowed: [role=presentation]"), so the
1.3.1 failure this branch fixes was not fixed. aria-hidden takes the wrapper out of the
accessibility tree, which axe skips, and the list stays one item for assistive technology.
The close label is empty and mouse-only; keyboard and screen-reader users have the
#observablehq-sidebar-toggle checkbox.
@oluies

oluies commented Oct 31, 2026

Copy link
Copy Markdown
Author

Could a maintainer approve the workflow run for 6dd84a9? The Test workflow has been waiting for approval since Oct 27, as it does for PRs from forks.

In the meantime, yarn test:mocha in a node:20 container gives the same result on this branch as on 85e843e (838 passing, 5 pending, 2 failing for environmental reasons), and test:lint, test:prettier and test:tsc pass. Details are in the description. The branch is up to date with main.

Thanks!

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant