Background
Follow-up to #7255. Murphy's
2026-05-16 re-test:
The biggest problem is that you still can't cycle through the text properly
line by line to press links and such. You can only really get to links if
you already know where they are and navigate directly to them.
PR #7777 addresses the targeted
toolbar / measurement-node / online-count AT regressions, but this complaint is
structural and needs a separate design effort.
Root cause
Two structural exposures keep AT from navigating pad content the way it
navigates a regular web page:
-
Each line renders as <div class=\"ace-line\">, not <p> (src/static/js/domline.ts:64).
AT users rely on the <p> element for paragraph / line-by-line navigation
(P key in NVDA/JAWS rotor). With <div>, the whole pad is one
undifferentiated block.
-
innerdocbody carries role=\"textbox\" aria-multiline=\"true\"
(src/static/js/ace.ts:299). This tells AT the editor is a flat editable
surface — links inside (<a href>) often don't surface in the rotor /
elements list because the screen reader treats the editor as a text input,
not a document.
Headings already work (ep_headings2 emits <h1>–<h4> via the
aceDomLineProcessLineAttributes hook).
Why we can't just swap <div> → <p>
The naïve fix — change document.createElement('div') to 'p' in
domline.ts — was scoped and rejected:
- Plugins extensively target line
<div>s with selectors like
iframe.contents().find('div.ace-line'), .innerdocbody div,
div[data-author=…]. Silently switching the tag would break a long tail of
plugins with no compile-time signal — bugs would trickle in over weeks.
<p> is phrasing content per HTML5 and cannot contain block-level children
(<ul>, <ol>, etc.). Existing list lines wrap themselves with
<ul><li>…</li></ul> inside the line node — browser auto-fixup would close
the <p> before the <ul>, breaking rendering.
- Even with the per-line list pattern, AT would see N single-item lists
(one <ul> per line) instead of one multi-item list — also wrong.
A flag-gated, opt-in switch with a deprecation cycle is theoretically possible
but is a multi-release rollout, not an a11y bug fix.
Proposed approach: AT-only read mirror
Render a visually-hidden <article role=\"region\" aria-label=\"Pad content\">
mirror outside the contenteditable iframe. Sighted users see nothing change.
Plugin selectors keep hitting the live editor's <div class=\"ace-line\">s.
AT users get a regular document tree to browse.
Shape
- New module e.g.
src/static/js/at_mirror.ts.
- Subscribes to changeset apply /
editEvent and rebuilds the mirror,
throttled (~500 ms debounce) — AT doesn't need realtime updates while
the user is typing.
- Line model → mirror DOM mapping:
- Plain line →
<p> containing inline <a href> for URL spans.
- Heading line (ep_headings2
heading attribute) → <h1>…<h4>.
- List line →
<li> nested inside a single <ul> / <ol> per consecutive
run of list lines at the same level (not one ul-per-line like
ExportHtml.ts currently does).
- Code line →
<pre><code>.
- New skip link near the existing "Skip to editor":
"Read pad content (screen reader view)" → focuses #at-pad-mirror.
- Existing skip-to-content link still jumps to the editable view for editing.
Locale keys
pad.editor.atMirror.skipLink = "Read pad content (screen reader view)"
pad.editor.atMirror.region = "Pad content"
Out of scope for this issue
- Live edit support inside the mirror (would require a typing handler that
forwards back to the editor — not in scope for v1).
- Author colors / highlighting in the mirror (probably noise for AT).
- Authorship metadata in the mirror.
Acceptance criteria
Effort estimate
3–5 days of implementation + at least one round of real-screen-reader testing.
Refs #7255 #7777
Background
Follow-up to #7255. Murphy's
2026-05-16 re-test:
PR #7777 addresses the targeted
toolbar / measurement-node / online-count AT regressions, but this complaint is
structural and needs a separate design effort.
Root cause
Two structural exposures keep AT from navigating pad content the way it
navigates a regular web page:
Each line renders as
<div class=\"ace-line\">, not<p>(src/static/js/domline.ts:64).AT users rely on the
<p>element for paragraph / line-by-line navigation(P key in NVDA/JAWS rotor). With
<div>, the whole pad is oneundifferentiated block.
innerdocbodycarriesrole=\"textbox\" aria-multiline=\"true\"(
src/static/js/ace.ts:299). This tells AT the editor is a flat editablesurface — links inside (
<a href>) often don't surface in the rotor /elements list because the screen reader treats the editor as a text input,
not a document.
Headings already work (ep_headings2 emits
<h1>–<h4>via theaceDomLineProcessLineAttributeshook).Why we can't just swap
<div>→<p>The naïve fix — change
document.createElement('div')to'p'indomline.ts— was scoped and rejected:<div>s with selectors likeiframe.contents().find('div.ace-line'),.innerdocbody div,div[data-author=…]. Silently switching the tag would break a long tail ofplugins with no compile-time signal — bugs would trickle in over weeks.
<p>is phrasing content per HTML5 and cannot contain block-level children(
<ul>,<ol>, etc.). Existing list lines wrap themselves with<ul><li>…</li></ul>inside the line node — browser auto-fixup would closethe
<p>before the<ul>, breaking rendering.(one
<ul>per line) instead of one multi-item list — also wrong.A flag-gated, opt-in switch with a deprecation cycle is theoretically possible
but is a multi-release rollout, not an a11y bug fix.
Proposed approach: AT-only read mirror
Render a visually-hidden
<article role=\"region\" aria-label=\"Pad content\">mirror outside the contenteditable iframe. Sighted users see nothing change.
Plugin selectors keep hitting the live editor's
<div class=\"ace-line\">s.AT users get a regular document tree to browse.
Shape
src/static/js/at_mirror.ts.editEventand rebuilds the mirror,throttled (~500 ms debounce) — AT doesn't need realtime updates while
the user is typing.
<p>containing inline<a href>for URL spans.headingattribute) →<h1>…<h4>.<li>nested inside a single<ul>/<ol>per consecutiverun of list lines at the same level (not one ul-per-line like
ExportHtml.tscurrently does).<pre><code>."Read pad content (screen reader view)" → focuses
#at-pad-mirror.Locale keys
pad.editor.atMirror.skipLink= "Read pad content (screen reader view)"pad.editor.atMirror.region= "Pad content"Out of scope for this issue
forwards back to the editor — not in scope for v1).
Acceptance criteria
sr-onlyclip-rect pattern) but exposed to AT.that slot is already taken by
#skip-to-content; new link sits next toit).
<h1>-<h4>).hrefandvisible text as the accessible name.
<ul>or<ol>per run.a11y fixes from Inaccessibility to screenreaders #7255.
once a draft build is available).
Effort estimate
3–5 days of implementation + at least one round of real-screen-reader testing.
Refs #7255 #7777