` prose | yes |
+| non-editable `role="group"` | yes |
+| `role="textbox"` + `aria-multiline` + `contenteditable` (the editor's block) | no |
+| `contenteditable` with **no** explicit role | no |
+
+The last row is the one that matters: a plain `contenteditable` loses the link just as thoroughly,
+so **`role="textbox"` is not responsible and this change did not regress link announcement**. It is
+`contenteditable` itself, and it is VoiceOver's behavior for editable content rather than anything
+the editor can attribute its way out of.
+
+**Watch out when testing this by hand:** the inline toolbar's controls stay reachable to VoiceOver
+after the toolbar is dismissed (see Case 11 — the DOM is clean, VoiceOver's tree isn't, and
+re-navigating doesn't fix it), and their announcement is the bare tool name — the Link button reads
+as "link". Reading *past* the block will therefore find the word "link" from the control that
+applied the formatting rather than from the text. Interact with the block so the reading stays
+inside it.
+
+**Result:** ☐ Pass ☐ Fail
+**Notes:**
+
+## Case 18 — Filtered-out toolbox items are unreachable (automated)
+
+`@editorjs/ui-kit` 2.0.0 puts a `hidden` attribute on items the toolbox search filters out, not
+just a CSS class. This checks that the attribute does its job: a result the sighted user can no
+longer see must not still be reachable by the virtual cursor.
+
+**Steps:** Open the toolbox, type a query matching nothing into the search field, then explore the
+popover with `VO+Right`.
+
+**Expected:** No menu items are reachable while the query matches nothing (and they are, before you
+type — otherwise the check proves nothing).
+
+**Result:** ☐ Pass ☐ Fail
+**Notes:**
+
+---
+
+## Summary
+
+- **All 18 cases: passing in one run** against real VoiceOver, confirmed after the
+ modified-arrow-key fix. The run before it failed seven cases (1, 2, 7, 11, 12, 13, 15) on the
+ editor bug described in the fifth finding above; fixing it turned all seven green together,
+ which is what confirms the diagnosis — a harness-flakiness explanation would not have moved
+ them as a group. Cases 3, 11 and 15 reached the form recorded above during task 8.12, and
+ Case 8 was settled after the `aria-owns` fix. Re-run with `yarn test:e2e:voiceover`
+ (`packages/editorjs/e2e/tests/voiceover.spec.ts`).
+- **Case 8: ☑ Follows.** The last case with no result, now run and asserted. VoiceOver announces
+ each highlighted tool by name off `aria-activedescendant` alone, with the selection intact —
+ see the case above for the measured output.
+- Follow-ups outstanding before archiving:
+ - ~~**Publish `@editorjs/ui-kit` 2.0.0** and drop the local `link:` resolution~~ — done, see
+ tasks.md 5a.3. The lockfile resolves the published 2.0.0 and the suite was re-run against it.
+ - Per-block identity for `aria-label="Paragraph"` once more block types exist — design.md
+ Open Questions. Not blocking.
+ - Inline toolbar not scoped per editor on chromium. Pre-existing, pinned by a
+ per-engine `test.fail()`, wants its own change. Not blocking.
+- **Overall verdict: ☐ Needs follow-up work before archiving, ☑ Ship as-is.** Screen-reader
+ verification is complete: every case is automated, run, and passing in a single run, with no
+ manual checklist left. The dependency publish that was blocking has landed. The suite earned
+ its keep twice over — beyond settling Case 8, it caught a keyboard trap that no
+ attribute-level assertion could have seen.
diff --git a/openspec/specs/tools/spec.md b/openspec/specs/tools/spec.md
index 49482b2e..a6f7c8cb 100644
--- a/openspec/specs/tools/spec.md
+++ b/openspec/specs/tools/spec.md
@@ -96,3 +96,30 @@ The system SHALL have all inline tools (Bold, Italic, Link) implement the same c
Cross-referenced across `packages/tools/bold/src/index.ts`, `packages/tools/italic/src/index.ts`, `packages/tools/inline-link/src/index.ts`, and validated indirectly by the generic facade test `packages/sdk/src/tools/facades/BaseToolFacade.spec.ts`.
+### Requirement: Accessible paragraph block
+`Paragraph` SHALL expose its contenteditable text element as an editable text field to assistive technology, via `role="textbox"`, `aria-multiline="true"`, and a non-empty `aria-label` identifying it as a paragraph block.
+
+#### Scenario: Paragraph contenteditable is an accessible textbox
+- **GIVEN** a `Paragraph` instance has lazily created its contenteditable `
`
+- **WHEN** the element is inspected
+- **THEN** it has `role="textbox"`, `aria-multiline="true"`, and a non-empty `aria-label`
+
+#### Scenario: Every block is exposed as a text field of its own
+- **GIVEN** the editor has rendered a document of several paragraph blocks
+- **WHEN** the accessibility tree is inspected
+- **THEN** each block exposes its own `role="textbox"` with its own accessible name
+- **AND** assistive technology can move between them in document order
+
+### Requirement: Paragraph placeholder is announced
+A contenteditable element has no native `placeholder`, so an empty block would otherwise be announced as an unlabelled empty field with no indication of what belongs in it. `Paragraph` SHALL expose the placeholder it is configured with (`config.placeholder`, which the editor fills from its own `placeholder` option for the default block tool) as `aria-placeholder` on its contenteditable element, and SHALL omit the attribute when no placeholder is configured.
+
+#### Scenario: Configured placeholder reaches assistive technology
+- **GIVEN** the editor is configured with a `placeholder` and `Paragraph` is the default block tool
+- **WHEN** an empty paragraph block's contenteditable element is inspected
+- **THEN** it has `aria-placeholder` set to the configured text
+
+#### Scenario: No placeholder configured
+- **GIVEN** the editor is configured without a `placeholder`
+- **WHEN** a paragraph block's contenteditable element is inspected
+- **THEN** it has no `aria-placeholder` attribute
+
diff --git a/openspec/specs/ui/spec.md b/openspec/specs/ui/spec.md
index cc8264a8..f62d03e7 100644
--- a/openspec/specs/ui/spec.md
+++ b/openspec/specs/ui/spec.md
@@ -106,3 +106,198 @@ The system SHALL provide `ToolboxUI`, a searchable popover of block tools popula
Implemented in `src/Toolbox/Toolbox.ts`, `src/Toolbox/events/*`, `src/Toolbox/ToolboxConfigEntry.ts`.
+### Requirement: Accessible blocks holder
+`BlocksUI` SHALL expose its contenteditable blocks holder element as a structural container, via `role="group"` — the holder itself is not an editable region; each block owns its own `role="textbox"` and accessible name (see the `tools` spec's `Paragraph` requirement), which avoids nesting a `textbox` role inside another `textbox` role between the holder and its contenteditable block children.
+
+#### Scenario: Blocks holder exposes group role
+- **GIVEN** `BlocksUI` has rendered the blocks holder element
+- **WHEN** the element is inspected
+- **THEN** it has `role="group"`
+
+#### Scenario: Blocks holder does not nest an editable region
+- **GIVEN** the editor has rendered a single paragraph block
+- **WHEN** the accessibility tree beneath the blocks holder is inspected
+- **THEN** exactly one element exposes `role="textbox"` — the block's contenteditable element, not the holder
+
+### Requirement: Accessible floating toolbar
+`ToolbarUI` SHALL expose its actions container with `role="toolbar"` and an accessible name distinguishing it from the inline toolbar. Its plus-button control SHALL have an accessible name (`aria-label`) describing its action, and SHALL advertise the toolbox menu it controls via `aria-haspopup="menu"` and an `aria-expanded` state kept in sync with whether the toolbox is open.
+
+#### Scenario: Toolbar actions container has toolbar role
+- **GIVEN** `ToolbarUI` has rendered its actions container
+- **WHEN** the element is inspected
+- **THEN** it has `role="toolbar"` and a non-empty `aria-label`
+
+#### Scenario: Plus-button has an accessible name
+- **GIVEN** `ToolbarUI` has rendered the plus-button
+- **WHEN** the element is inspected
+- **THEN** it has a non-empty `aria-label`
+
+### Requirement: The block actions toolbar is a single tab stop
+`role="toolbar"` declares that the group is reached once by `Tab` and navigated internally with the arrow keys; a browser does not provide that on its own. `ToolbarUI` SHALL therefore maintain a roving tabindex over its own controls — exactly one in the tab order at a time, moved by the horizontal arrow keys — and SHALL declare its `aria-orientation`. Only the container's own controls take part: the toolbox popover renders *inside* the actions container, and its menu items are navigated by `@editorjs/ui-kit`, so keys arriving from within it are left alone.
+
+The pattern covers the **unmodified** arrow keys only. `ToolbarUI` SHALL ignore an arrow key pressed with any modifier held, and SHALL NOT call `preventDefault()` on it. A modified arrow always belongs to something else, and a screen reader is the case that matters here: VoiceOver moves its cursor with VO+Right and VO+Left, which reach the page as `Control`+`Option`+arrow. Swallowing those traps a VoiceOver user on whichever control their cursor reached, since Safari focuses a control when the cursor lands on it.
+
+#### Scenario: Modified arrow keys are left to their owner
+- **GIVEN** a control of the block actions toolbar holds focus
+- **WHEN** an arrow key is pressed with `Control`+`Option`, `Meta`, or `Shift` held
+- **THEN** the toolbar does not handle it and the event is not prevented
+- **AND** an unmodified arrow key is still handled by the toolbar
+
+#### Scenario: Only one control is in the tab order
+- **GIVEN** `ToolbarUI` has rendered its actions container
+- **WHEN** its controls are inspected
+- **THEN** exactly one of them has `tabindex="0"` and the rest have `tabindex="-1"`
+- **AND** the container declares `aria-orientation`
+
+#### Scenario: Toolbox keyboard navigation is not intercepted
+- **GIVEN** the toolbox has been opened from the plus-button
+- **WHEN** the user presses a vertical arrow key inside the menu
+- **THEN** the toolbox handles it and moves focus between menu items, unaffected by the toolbar's own arrow-key handling
+
+#### Scenario: Plus-button reports the toolbox state
+- **GIVEN** the toolbox is closed
+- **WHEN** the plus-button is inspected
+- **THEN** it has `aria-haspopup="menu"` and `aria-expanded="false"`
+- **WHEN** the plus-button is activated and the toolbox opens
+- **THEN** its `aria-expanded` becomes `"true"`
+- **WHEN** the toolbox is dismissed
+- **THEN** its `aria-expanded` returns to `"false"`
+
+### Requirement: Focus returns to the control that opened the toolbox
+Following the WAI-ARIA menu button pattern, dismissing the toolbox SHALL return DOM focus to the plus-button that opened it, so that keyboard and screen-reader users keep their place in the editor. `ToolbarUI` SHALL therefore move focus onto the plus-button when it opens the toolbox — Safari does not focus a button when it is clicked, which would otherwise leave the popover with `document.body` as the element to restore focus to.
+
+#### Scenario: Dismissing the toolbox restores focus
+- **GIVEN** the toolbox has been opened from the plus-button
+- **WHEN** the user presses `Escape`
+- **THEN** the toolbox closes and DOM focus is on the plus-button
+
+#### Scenario: Inserting a block does not lose focus
+- **GIVEN** the toolbox has been opened from the plus-button
+- **WHEN** the user activates a menu item and a block is inserted
+- **THEN** DOM focus is on a named control within the editor, not on the document body
+
+### Requirement: Toolbox search hides filtered-out items from assistive technology
+Filtering the toolbox by its search field SHALL remove non-matching items from the accessibility tree — not only hide them visually — so that assistive technology cannot reach a result the sighted user can no longer see. The `hidden` attribute that achieves this is applied by `@editorjs/ui-kit`.
+
+#### Scenario: Filtered-out items leave the accessibility tree
+- **GIVEN** the toolbox is open and exposes its block tools as menu items
+- **WHEN** the user types a query that matches none of them
+- **THEN** no `role="menuitem"` element remains exposed
+- **WHEN** the query is changed to one that matches again
+- **THEN** the matching item is exposed once more
+
+### Requirement: Accessible toolbox menu
+`ToolboxUI` SHALL name the menu its popover renders, so that the list of block types is announced as a named menu. The accessible names of the individual items and the `menu`/`menuitem` roles are produced by `@editorjs/ui-kit` from the `title` each tool is registered with.
+
+#### Scenario: Toolbox exposes a named menu of block types
+- **GIVEN** the toolbox has been opened
+- **WHEN** the accessibility tree is inspected
+- **THEN** a `role="menu"` element with a non-empty accessible name is exposed
+- **AND** each registered block tool is exposed as a `role="menuitem"` named after its toolbox title
+
+#### Scenario: Toolbox items are operable by keyboard
+- **GIVEN** the toolbox has been opened
+- **WHEN** the user presses `ArrowDown`
+- **THEN** DOM focus moves onto the first menu item
+- **WHEN** the user presses `Enter`
+- **THEN** the corresponding block is inserted
+
+### Requirement: Accessible inline toolbar
+`InlineToolbarUI` SHALL name the toolbar its popover renders, so that it is distinguishable from the block actions toolbar. The inline popover does not move DOM focus onto its buttons — doing so drops the text selection the inline tools operate on — so `InlineToolbarUI` SHALL mirror the item highlighted by keyboard navigation onto `aria-activedescendant` of the element that holds focus, and SHALL clear it once nothing is highlighted or the toolbar is dismissed.
+
+`aria-activedescendant` is only resolved when it names an element inside the focused element's subtree, or one the focused element claims via `aria-owns`. The toolbar is rendered as a *sibling* of the blocks holder, so setting the reference alone leaves it pointing outside the focused subtree, where assistive technology drops it. `InlineToolbarUI` SHALL therefore claim its popover via `aria-owns` on the same element, set and cleared together with the reference so the two cannot disagree. This re-parents the popover in the accessibility tree only — the DOM, and hence the text selection the inline tools operate on, is untouched.
+
+The element carrying the pair SHALL be one inside the editor this toolbar belongs to. On a page with more than one editor, a toolbar that has been raised for a selection in another instance would otherwise claim its own popover from that instance's holder, telling assistive technology that controls operating on one document belong to another.
+
+The accessible names of the individual buttons, their `button` role and their `aria-pressed` state are produced by `@editorjs/ui-kit` from the `title` and `isActive` each inline tool already provides.
+
+#### Scenario: Inline toolbar exposes named formatting buttons
+- **GIVEN** a range of text is selected
+- **WHEN** the accessibility tree is inspected
+- **THEN** a `role="toolbar"` element with a non-empty accessible name is exposed
+- **AND** each available inline tool is exposed as a `role="button"` named after its title
+
+#### Scenario: Inline tool reports whether it is applied
+- **GIVEN** a range of unformatted text is selected
+- **THEN** the bold button exposes `aria-pressed="false"`
+- **WHEN** the bold tool is applied and the text is selected again
+- **THEN** the bold button exposes `aria-pressed="true"`
+
+#### Scenario: Highlighted inline tool is announced without taking focus
+- **GIVEN** the inline toolbar is shown for a text selection
+- **WHEN** the user navigates the toolbar with the arrow keys
+- **THEN** the element holding focus exposes `aria-activedescendant` referencing the highlighted button
+- **AND** the text selection is preserved
+- **WHEN** the toolbar is dismissed
+- **THEN** `aria-activedescendant` is removed
+
+#### Scenario: The reference is not claimed from outside the editor
+- **GIVEN** the inline toolbar has an item highlighted by keyboard navigation
+- **WHEN** the element holding focus belongs to a different editor instance, or to no editor at all
+- **THEN** neither `aria-activedescendant` nor `aria-owns` is written onto it
+
+#### Scenario: The highlighted item is reachable from the element holding focus
+- **GIVEN** the inline toolbar is shown and an item has been highlighted by keyboard navigation
+- **WHEN** the reference is resolved from the element holding focus
+- **THEN** that element also exposes `aria-owns` naming the popover
+- **AND** the element named by `aria-activedescendant` is inside the popover so named
+- **AND** it is *not* a DOM descendant of the element holding focus, the relationship being an accessibility-tree one only
+- **WHEN** the toolbar is dismissed
+- **THEN** `aria-owns` is removed alongside `aria-activedescendant`, leaving no claim on a destroyed popover
+
+### Requirement: Inline toolbar announces its own availability
+The inline toolbar appears next to a text selection without moving DOM focus or the virtual cursor, so nothing otherwise tells a screen reader user that it exists or that `Tab` now leads somewhere new. `InlineToolbarUI` SHALL announce its appearance through a live region, which SHALL remain mounted across popover re-renders — a region that is not already in the accessibility tree when its text changes is unreliable across assistive tech.
+
+The announcement SHALL fire once per genuine open: not again while the toolbar stays open across selection changes, and again when the toolbar is dismissed and shown anew.
+
+The announcement is written on a short delay, so that an identical repeat still reads as a change to the region. A toolbar dismissed inside that delay SHALL NOT announce itself — the pending write is cancelled rather than allowed to describe a toolbar that is already gone.
+
+#### Scenario: A toolbar dismissed before the announcement lands stays silent
+- **GIVEN** the inline toolbar has just appeared and its announcement is scheduled but not yet written
+- **WHEN** the toolbar is dismissed before the delay elapses
+- **THEN** the live region is never filled with the announcement
+
+#### Scenario: Toolbar announces that it can be entered
+- **GIVEN** no inline toolbar is shown
+- **WHEN** a range of text is selected and the toolbar appears
+- **THEN** the toolbar's live region is filled with a message naming the toolbar and the key that enters it
+
+#### Scenario: Announcement does not repeat while the toolbar stays open
+- **GIVEN** the inline toolbar is shown for a text selection
+- **WHEN** the selection is extended or shrunk while remaining non-empty
+- **THEN** the toolbar's popover is rebuilt
+- **AND** no further announcement is made
+
+#### Scenario: Announcement repeats when the toolbar is genuinely reopened
+- **GIVEN** the inline toolbar has been shown and then dismissed
+- **WHEN** a range of text is selected again
+- **THEN** the announcement is made again
+
+### Requirement: The inline toolbar shown is the one built for the current selection
+Building the toolbar is asynchronous — every tool contributes an awaited `getToolbarConfig` — so two selection changes can be in flight at once and resolve in either order. `InlineToolbarUI` SHALL discard a build that was superseded while it was awaiting, rather than letting whichever finishes last take effect. A superseded build SHALL neither replace the popover nor reposition or re-announce the toolbar.
+
+This is an accessibility requirement and not only a rendering one: each tool's `isActive` and `onActivate` close over the range the build started from, so a stale build puts up buttons whose `aria-pressed` describes text the user has moved off, and which act on it when activated.
+
+Being dismissed counts as superseding: a build still awaiting when the toolbar is hidden SHALL NOT put it back up.
+
+#### Scenario: A superseded build does not take effect
+- **GIVEN** a toolbar build is awaiting its tools
+- **WHEN** a further selection change starts a build of its own and the first one resolves afterwards
+- **THEN** the popover in the document is the one built for the later selection
+- **AND** the earlier build neither repositions nor re-announces the toolbar
+
+#### Scenario: A build outstanding at dismissal does not resurrect the toolbar
+- **GIVEN** a toolbar build is awaiting its tools
+- **WHEN** the selection is dropped and the toolbar is hidden before the build resolves
+- **THEN** no popover is added to the document when it does
+
+### Requirement: Dismissed inline toolbar leaves the accessibility tree
+`InlineToolbarUI` SHALL remove its tools from the document when the toolbar is dismissed, rather than only hiding them visually. A control that is invisible but still exposed leaves a screen reader able to reach and activate a tool that is no longer available.
+
+Note that removal is the limit of what the editor controls: VoiceOver has been observed to keep reaching a dismissed tool after it has left the DOM, and no navigation clears it (recorded under Case 11 in `voiceover-verification.md`). The requirement is therefore stated over the document, which is verifiable, rather than over what any given screen reader will do with it.
+
+#### Scenario: Inline tools are removed once the toolbar is dismissed
+- **GIVEN** the inline toolbar is shown for a text selection
+- **WHEN** the selection is collapsed and the toolbar is dismissed
+- **THEN** no `role="button"` named after an inline tool remains exposed in the document's accessibility tree
+
diff --git a/packages/dom-adapters/package.json b/packages/dom-adapters/package.json
index 8fead328..da5c314b 100644
--- a/packages/dom-adapters/package.json
+++ b/packages/dom-adapters/package.json
@@ -34,7 +34,7 @@
"typescript": "^5.5.4"
},
"dependencies": {
- "@editorjs/dom": "^1.1.0",
+ "@editorjs/dom": "^1.2.0",
"@editorjs/sdk": "workspace:^",
"inversify": "^8.1.0",
"reflect-metadata": "^0.2.2"
diff --git a/packages/editorjs/.gitignore b/packages/editorjs/.gitignore
index 854a697d..adc8b7e6 100644
--- a/packages/editorjs/.gitignore
+++ b/packages/editorjs/.gitignore
@@ -20,5 +20,11 @@ dist/*
coverage/
reports/
+# playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+
# stryker temp files
.stryker-tmp
diff --git a/packages/editorjs/e2e/README.md b/packages/editorjs/e2e/README.md
new file mode 100644
index 00000000..2754bfb0
--- /dev/null
+++ b/packages/editorjs/e2e/README.md
@@ -0,0 +1,335 @@
+# e2e tests
+
+Playwright tests that drive a real Chromium instance against the fixture app in
+`e2e/fixtures/`, which mounts the actual `EditorJS` bundle (imported from `src/`,
+no build step required).
+
+Run with `yarn test:e2e` (or `yarn test:e2e:ui`). Vite serves the fixture; see
+`e2e/vite.config.ts` and `playwright.config.ts` for wiring. This runs
+`aria.spec.ts`, `editor.spec.ts`, and `axe.spec.ts` against chromium and
+webkit. It does **not** run `voiceover.spec.ts` — see below.
+
+## The suite tests `dist`, so it builds first
+
+The fixture imports this package's `src`, but that reaches `@editorjs/ui` and the
+tools through their package `exports` — which point at `dist`, not source. Editing
+`packages/ui/src` therefore changes nothing the suite can see until that package is
+rebuilt. Both playwright configs run `yarn build:dependencies` as part of their
+`webServer` command so this can't be got wrong, at the cost of a few seconds per run.
+
+This is worth knowing about rather than just relying on: it is a failure mode that
+lies in *both* directions. An unbuilt fix looks broken, and — more dangerously — an
+unbuilt regression looks fine. If you bypass the configs (`npx playwright test` against
+a server you started yourself, `reuseExistingServer` picking up a stale one), you are
+back to auditing whatever was built last.
+
+## Shared helpers (`e2e/support/`)
+
+`e2e/support/editor.ts` holds what every spec needs: `mountDocument`,
+`selectParagraphText`, `tabToInlineTool`, and the `PLACEHOLDER` the fixture
+configures. It sits outside `e2e/tests` so Playwright's default `testMatch`
+can't mistake it for a spec.
+
+## Fixture documents
+
+The fixture mounts one paragraph per `text` search param, so a test needing a
+document other than the default remounts with one:
+
+| URL | Document |
+| --- | --- |
+| `/` | a single `Hello world` block (the default every early test assumes) |
+| `/?text=Alpha&text=Beta` | two blocks, `Alpha` then `Beta` |
+| `/?text=` | a single empty block — how the placeholder tests get one |
+| `/?text=&placeholder=` | an empty block with **no** `placeholder` option set at all |
+| `/?editors=2` | two editors on one page, for anything that has to stay unique per instance |
+
+The fixture also configures the editor's `placeholder`, which reaches the
+paragraph tool as `config.placeholder` and is exposed as `aria-placeholder`.
+`?placeholder=` (empty) omits the option entirely, which is the only way to get a
+block that must *not* carry the attribute — `?placeholder=Something` overrides it.
+
+`?editors=2` is worth reaching for whenever the editor writes a document-wide
+`id`, since ARIA references (`aria-owns`, `aria-activedescendant`,
+`aria-labelledby`) resolve by `getElementById` across the *whole page*: an id
+that is unique within one editor but fixed across instances silently points the
+second editor's markup at the first editor's elements. Note this mode currently
+also exposes a scoping bug of its own — see the `test.fail()` on
+"gives each editor on a page its own inline toolbar" in `aria.spec.ts`.
+
+## Accessibility audits (`axe.spec.ts`)
+
+`@axe-core/playwright` runs a static WCAG-rule audit (missing accessible
+names, invalid roles, contrast, etc.) against the editor in its default,
+toolbox-open, and inline-toolbar-open states. It's part of the normal
+`test:e2e` run — no separate command. This catches a different class of bug
+than `aria.spec.ts`: axe flags anything that violates a rule regardless of
+whether a test happens to assert on it, but it can't tell you whether a
+screen reader announces the *right* thing — that's `voiceover.spec.ts`.
+
+Each audit waits for CSS animations to finish first. The popovers fade in over
+~100ms and axe samples computed styles when it runs, so an audit taken
+mid-animation measures a half-transparent panel and can report a contrast
+violation that isn't real. Violations are asserted as `id (impact): help`
+summaries rather than raw result objects, so a failure names the rule instead
+of burying it in serialised nodes.
+
+## Screen-reader tests (`voiceover.spec.ts`)
+
+`@guidepup/playwright` drives a real, running VoiceOver instance against
+Safari and exposes what it actually announced (`lastSpokenPhrase()`,
+`spokenPhraseLog()`) as assertable strings — this is the automated
+counterpart to `openspec/changes/add-editor-aria-semantics/voiceover-verification.md`.
+
+Run it with:
+
+```sh
+yarn test:e2e:voiceover
+```
+
+This needs its own config (`playwright.voiceover.config.ts`, `workers: 1`,
+headed) and is **macOS-only** — screen readers can't drive a headless
+browser and can't run more than one instance at a time, so it's excluded
+from `test:e2e` and isn't wired into CI (which doesn't run any of this
+package's e2e suite yet). Before the first run:
+
+```sh
+npx @guidepup/setup setup # configure the machine for screen reader automation
+npx @guidepup/setup install # install the screen reader assets Guidepup drives
+```
+
+### Settling an open question: measure first, then assert
+
+`Case 8` — whether VoiceOver follows `aria-activedescendant` on the blocks
+holder — sat unrun for the life of this change, on the grounds that a test
+asserting either outcome would just encode a guess. That reasoning is worth
+resisting, and the way out is a two-step one:
+
+1. **Land the case as a measurement.** Assert only the parts already known to
+ hold (the reference resolves, the selection survives), then *print* what
+ VoiceOver actually said. Such a test cannot fail for the wrong reason, and it
+ runs by command instead of by checklist.
+2. **Convert the reading into an assertion**, whichever way it landed — a
+ positive match, or a pinned absence in the style of Cases 3 and 15.
+
+Case 8 went through exactly this and came out positive: arrowing to Bold is
+announced `"Bold toggle button"`, arrowing on to Italic `"Italic toggle button"`,
+each naming the item the reference points at, with focus and selection untouched.
+
+Three details from that run worth reusing:
+
+- **Take two arrow steps, not one.** `VO-Z` repeats the *last* spoken phrase, so
+ a single match could be VoiceOver echoing something said before the key
+ arrived. Two steps naming different tools rules that out.
+- **Anchor to the name read off the element**, not to a hardcoded `"Bold"`.
+ Which tool a given press count lands on is ui-kit's business.
+- **Poll for the announcement; never sample it once.** Speech is asynchronous
+ and nothing signals its arrival, so a `VO-Z` issued straight after the action
+ returns whatever VoiceOver last *finished* saying — often the previous
+ announcement entirely. This case asserted correctly and still failed that way
+ once, reading back `"hello world selected"` from the click that opened the
+ toolbar. Poll `VO-Z` on a slow interval (1.5s here) so the repeat doesn't
+ interrupt the phrase you're waiting for, and let the timeout be what
+ distinguishes "never said it" from "hasn't said it yet".
+- **`syncCursorToFocus()` is not optional, and forgetting it fails only in the
+ full suite.** VoiceOver reports state — `aria-activedescendant` included — for
+ the element *its own cursor* is on, and it does not observe Playwright's
+ synthesized events, so a page-driven focus change leaves the cursor wherever
+ the previous test left it. Case 8 omitted the sync and passed in isolation,
+ where a fresh session's cursor happened to be close enough, then failed after
+ sixteen other cases had moved it: twenty seconds of polling with VoiceOver sat
+ on the stale phrase the whole time, which reads exactly like "the feature is
+ broken". **A VoiceOver case that passes alone and fails in the suite is a
+ cursor-position bug until proven otherwise.**
+
+That last rule has a limit, and the run after it found the limit. Seven cases
+failed at once, all of them stuck on the plus button, and none of it was the
+harness: the toolbar's roving tabindex handled `ArrowLeft`/`ArrowRight` with any
+modifiers held, and **VO+Right and VO+Left arrive as `Control`+`Option`+arrow**.
+Once VoiceOver's cursor reached the plus button — Safari focuses a control when
+the cursor lands on it — the editor swallowed every attempt to move off it. A
+VoiceOver user was trapped there. See "When the whole suite tilts" below.
+
+And the reason it stayed open so long: the markup was broken the whole time.
+`aria-activedescendant` named an element outside the focused subtree, which is
+invalid and unfollowable — checkable in any browser, with no screen reader
+running. Worth testing an "open question about AT behaviour" for the possibility
+that it is a bug in disguise.
+
+### Three VoiceOver behaviors the cases are built around
+
+Each was established by experiment, because the failing case had more than one
+available explanation and only some of them were the editor's fault. In all
+three the markup turned out to be correct and VoiceOver simply doesn't report
+the thing — which is the null hypothesis worth starting from when one of these
+fails. Full results are recorded per case in `voiceover-verification.md`.
+
+- **`aria-haspopup` suppresses `aria-expanded` in VoiceOver.** A plain button
+ announces "expanded"/"collapsed" fine; add `aria-haspopup` in any form and it
+ switches to "pop-up button" and drops the state. `aria-controls` does not
+ restore it. So Case 15 asserts what VoiceOver does expose instead of the state.
+- **`contenteditable` suppresses link announcement at the browse cursor** — not
+ `role="textbox"`, which a plain contenteditable proves innocent. Links inside a
+ block are only announced once you interact with the field (`VO+Shift+Down`),
+ which is why Case 17 calls `voiceOver.interact()`.
+- **Container names don't reach the browse cursor.** `VO+Right` steps from the
+ block straight onto the plus button without ever announcing the `role="toolbar"`
+ named "Block actions" that contains it. Naming a container is not what makes it
+ reachable — an unnamed group being transparent (Case 1) does not imply a named
+ one is announced. Case 3 pins the absence rather than asserting the name.
+
+Cases 3 and 15 assert these absences rather than skipping them, so a future
+VoiceOver that starts reporting the state or the container name fails the suite
+and prompts the case back into a positive assertion.
+
+### When the whole suite tilts
+
+One case failing is usually about that case. **Several unrelated cases failing
+the same way is a product bug, and the shape of the failure names it.** Seven
+cases failed together here — cases 1, 2, 7, 11, 12, 13 and 15 — with three
+different-looking symptoms: reading the plus button where the paragraph should
+be, not finding a block that is plainly in the document, and a forward walk that
+never reached anything. One thing explains all three: VoiceOver's cursor could
+get onto the plus button and never get off. The editor was calling
+`preventDefault()` on `Control`+`Option`+arrow.
+
+Two things made it look like flakiness rather than a bug, and both are worth
+recognising next time:
+
+- **It was intermittent across runs.** It only bites once the cursor has landed
+ on a toolbar control, and whether a given `next()` step goes there or onto the
+ live region depends on VoiceOver's tree state. A previous full run passed 16
+ of 17 with the same code.
+- **Nothing in the editor's markup was wrong.** Playwright's failure snapshot
+ showed a correct accessibility tree — `group > textbox "Paragraph"`, then
+ `status`, then `toolbar > button "Add block"`. The tree was right and the
+ keyboard was broken, which is not a distinction an ARIA-attribute assertion
+ can make.
+
+What settled it took no screen reader at all: the snapshot marked the plus
+button `[active]`, a headless mount proved focus is on `` there, and a
+scratch spec pressing `Control+Alt+ArrowRight` at a focused plus button showed
+`defaultPrevented: true`. **Reach for a headless probe of the mechanism before
+another VoiceOver run** — a real run costs six minutes and answers a narrower
+question. The regression test that came out of it lives in `aria.spec.ts`
+("leaves modified arrow keys to their real owner") rather than here, because the
+defect is a swallowed key and asserting it needs no screen reader.
+
+### Two traps when writing VoiceOver assertions
+
+- **VoiceOver answers from a stale tree until it re-navigates.** A change driven
+ from `page` — a click, a `fill()`, anything Playwright synthesises — is
+ invisible to VoiceOver until it runs a navigation command of its own, so a
+ scan taken straight afterwards can describe the page as it was before. Always
+ go through `resetCursor()`, which pairs `navigateToWebContent()` with the round
+ trip that refreshes it; mid-test resets need it as much as the initial mount
+ does. This is what makes Case 18 deterministic.
+- **A detached element is not an unreachable one, and item text for a control is
+ the bare tool name.** The dismissed inline toolbar's buttons stay reachable to
+ VoiceOver — it retains nodes the document has already dropped, and
+ `resetCursor()` does **not** clear them. This is not a leak in the editor:
+ `InlineToolbarUI#hide` destroys the popover, and `aria.spec.ts` proves the DOM
+ is clean afterwards on both engines. The residue is VoiceOver's, and there is
+ no navigation that makes it go away.
+
+ Since a control's item text is just its name (Link announces as `"link"`, Bold
+ as `"bold"`), any scan that walks *past* a block can match a control instead of
+ the content and pass with the content unchanged — which is how Case 17 passed
+ twice in three runs while asserting nothing. Bound the scan to the block with
+ `interact()`. Do not substitute "the toolbar is gone from the DOM" for that;
+ it's true and it doesn't help.
+
+You'll also need to grant the terminal/IDE running the tests Accessibility
+permissions (System Settings → Privacy & Security → Accessibility) the first
+time it tries to control VoiceOver. Running it takes over your screen and
+speaks through your speakers for the duration of the suite — it's a real
+screen-reader session, not a simulation.
+
+## Gotcha: `locator.press()` / `locator.selectText()` break caret tracking
+
+Editor.js nests a `contenteditable="true"` block (e.g. `.editorjs-paragraph
+[contenteditable="true"]`) inside the outer `.ejs-blocks` contenteditable host.
+The editor's input handling relies on the browser's *native* selection/caret
+state on that structure.
+
+`locator.press(key)` and `locator.selectText()` both call `element.focus()`
+programmatically on the specific (inner) element before acting. That JS-driven
+focus call bypasses the native click-to-place-caret flow and desyncs the
+editor's tracked caret/selection — keystrokes and selection-based UI (e.g. the
+inline toolbar) silently stop working, with no error thrown.
+
+**Fix:** interact the way a real user would — a real mouse `click()` (or
+`click({ clickCount: 3 })` to select a line) on the locator, then drive
+follow-up keys through the page-level `page.keyboard`, not `locator.press()`:
+
+```ts
+// caret placement / typing
+await paragraph.click();
+await page.keyboard.press('End');
+await page.keyboard.type('!');
+
+// text selection (for e.g. the inline toolbar)
+await paragraph.click({ clickCount: 3 }); // triple-click selects the paragraph
+```
+
+Avoid `locator.press()` and `locator.selectText()` anywhere in this suite.
+
+## Locators
+
+Use `getByRole`/`getByLabel` — every surface the suite touches is reachable by
+role now, including the popovers rendered by `@editorjs/ui-kit` (2.0.0+).
+
+| What | Locator |
+| --- | --- |
+| Blocks host | `getByRole('group')` |
+| Paragraph text (contenteditable) | `getByRole('textbox', { name: 'Paragraph' })` |
+| Floating toolbar | `getByRole('toolbar', { name: 'Block actions' })` |
+| Plus button | `getByRole('button', { name: 'Add block' })` |
+| Toolbox menu | `getByRole('menu', { name: 'Add block' })` |
+| Toolbox item | `getByRole('menuitem', { name: '
' })` (e.g. `Text`) |
+| Toolbox search | `getByRole('searchbox', { name: 'Search' })` |
+| Inline toolbar | `getByRole('toolbar', { name: 'Text formatting' })` |
+| Inline tool button | `getByRole('button', { name: '' })` (e.g. `Bold`) |
+
+Both toolbars need their name in the locator: the block actions toolbar and the
+inline toolbar are both `role="toolbar"`, and only the name tells them apart.
+
+`aria.spec.ts` asserts each of these directly, so it doubles as the regression
+test for the semantics the other specs rely on.
+
+### Notes on the popover surfaces
+
+The toolbox and inline toolbar are rendered by `@editorjs/ui-kit`, which derives
+item roles and accessible names from params the editor already passes it — the
+toolbox item's `toolbox.title`, the inline tool's `hint.title`. Nothing in the
+markup needs to be targeted by class anymore, and
+`.ce-popover-item[data-item-name="…"]` selectors should not be reintroduced.
+
+Two behaviors worth knowing when writing tests against them:
+
+- **The toolbox moves real DOM focus.** `ArrowDown` focuses the first
+ `menuitem`, so `toBeFocused()` is a valid assertion and `Enter` activates it.
+- **The inline toolbar does not.** Moving focus to a button drops the text
+ selection in WebKit, so navigating it only moves a highlight; which item is
+ current is exposed via `aria-activedescendant` on the element that holds focus
+ (the blocks holder, being the outermost contenteditable). Wait for the toolbar
+ to be visible before pressing an arrow key — until the popover is rendered its
+ key handler is not listening and the arrow just collapses the selection.
+
+ The holder also carries `aria-owns` naming the popover while an item is
+ highlighted, and that is load-bearing rather than decorative: the toolbar is a
+ *sibling* of the holder in the DOM, and `aria-activedescendant` is only resolved
+ when it names something inside the focused element's subtree or claimed by it via
+ `aria-owns`. **Asserting the attribute's value is not asserting that it resolves.**
+ Matching `aria-activedescendant` against the button's `id` passes identically
+ whether or not assistive tech can follow it — which is exactly how the missing
+ `aria-owns` survived a full suite. Assert over the resolved relationship instead;
+ `aria.spec.ts` has the pattern.
+- **Dismissing the toolbox returns focus to the plus button**, on both the
+ `Escape` and the insert-a-block path, so `toBeFocused()` on the plus button is
+ the right assertion afterwards. This only works because `ToolbarUI` focuses the
+ button itself when opening — Safari does not focus a `` on click, and
+ without it the popover restores focus to ``. Assert it on webkit; chromium
+ never reproduced the bug.
+- **Search filtering removes items from the accessibility tree.** Filtered-out
+ items get a `hidden` attribute alongside the CSS class, so
+ `getByRole('menuitem')` drops to zero rather than matching invisible items.
diff --git a/packages/editorjs/e2e/cypress-test-inventory.md b/packages/editorjs/e2e/cypress-test-inventory.md
new file mode 100644
index 00000000..8d74c652
--- /dev/null
+++ b/packages/editorjs/e2e/cypress-test-inventory.md
@@ -0,0 +1,604 @@
+# Editor.js Cypress Test Inventory with Expected Behavior (source: codex-team/editor.js @ 4ea9eb38)
+
+_Generated by re-reading each Cypress test's actual assertions in the v2 source repo. For every test, the description after the colon states the concrete triggering action and observable outcome, not just the test's English name._
+
+## block-ids.cy.ts
+
+- Block ids
+ - Should generate unique block ids for new blocks: Typing into two paragraph blocks and adding a Header block via the toolbox, then calling `editor.save()`, results in every block in the saved output having an `id` of type `string`.
+ - should preserve passed ids: Rendering the editor with two blocks that each have an explicit `id` (via `render()`), then calling `editor.save()`, returns blocks whose `id` values match the originally supplied ids in the same order.
+ - should preserve passed ids if blocks were added: After rendering two blocks with explicit ids and pressing Enter inside the first block to insert a new block in between (typing "Middle block" into it), `editor.save()` returns data where `blocks[0].id` still equals the first original id and `blocks[2].id` still equals the second original id (the new middle block takes index 1).
+ - should be stored at the Block wrapper's data-id attribute: After rendering two blocks with explicit ids, each `div.ce-block` element in the DOM has a `data-id` attribute equal to the corresponding block's id, in order.
+
+## copy-paste.cy.ts
+
+- Copy pasting from Editor
+ - pasting
+ - should paste plain text: Clicking into the first block and firing a paste event with `text/plain: 'Some plain text'` results in the block's content containing that plain text.
+ - should paste inline html data: Pasting a `text/html` payload of `Some text
` into a block results in the block's HTML containing `Some text `.
+ - should paste several blocks if plain text contains new lines: Pasting `text/plain` content `'First block\n\nSecond block'` (blank-line-separated) splits it into two separate `div.ce-block` elements whose text contents are `'First block'` and `'Second block'` respectively.
+ - should paste several blocks if html contains several paragraphs: Pasting `text/html` `'First block
Second block
'` produces two separate `div.ce-block` elements with text contents `'First block'` and `'Second block'`.
+ - should paste using custom data type: Pasting a custom `application/x-editor-js` MIME payload containing a JSON array of two paragraph block descriptors produces two blocks whose text contents match the pasted data (`'First block'`, `'Second block'`).
+ - should parse block tags: Pasting `text/html` `'First block Second block
'` (with the Header tool registered) creates an `h2.ce-header` element containing "First block" and a `div.ce-paragraph` containing "Second block"; `editor.save()` confirms `blocks[0]` is type `header` with `data.text === 'First block'` and `data.level === 2`, and `blocks[1]` is type `paragraph` with `data.text === 'Second block'`.
+ - should parse pattern: Pasting a bare image URL as `text/plain` (with the Image tool registered) auto-converts the pasted text into an image block, rendering an `img` element whose `src` attribute equals the pasted URL.
+ - should not prevent default behaviour if block's paste config equals false: For a custom tool with `pasteConfig = false` whose rendered element has a native `paste` listener, pasting text into that element results in the native paste event handler being called with `defaultPrevented: false` (i.e., Editor.js does not call `preventDefault()` on paste for that block).
+ - copying
+ - should copy inline fragment: Selecting all text within a single block (via `{selectall}`) and firing a copy event yields empty clipboard data, since Editor.js only intercepts copy for multi-block/cross-block selections and otherwise lets the browser handle it natively.
+ - should copy several blocks: After typing into two blocks and selecting from the second block's start up through the first block (Shift+Up), firing copy produces clipboard data where `text/html` matches two `` elements containing "First block" and "Second block", `text/plain` equals `'First block\n\nSecond block'`, and (after a tick, since it's set asynchronously) `application/x-editor-js` is a JSON array of two paragraph-tool entries with matching text.
+ - cutting
+ - should cut inline fragment: Selecting all text in a single block and firing a cut event yields empty clipboard data (same native-behavior fallback as inline copy).
+ - should cut several blocks: With two pre-existing paragraph blocks, selecting from the end of the last block up through the first (Shift+Up) and firing cut produces clipboard data with the same `text/html`, `text/plain`, and async `application/x-editor-js` JSON payload as the copy case, and afterward the editor DOM no longer contains "First block" or "Second block" text (the selected blocks were removed).
+ - should cut lots of blocks: With 50 pre-existing paragraph blocks, selecting all (`{ctrl+A}` twice) and firing cut produces an `application/x-editor-js` clipboard payload whose parsed JSON array has exactly 50 entries.
+
+## i18n.cy.ts
+
+- Editor i18n
+ - Toolbox
+ - should translate tool title in a toolbox: With an `i18n.messages.toolNames` dictionary mapping `Heading` to a translated string, opening the toolbox (clicking the block, then the plus button) shows the header entry's popover item (`[data-item-name=header]`) containing the translated text instead of the English default.
+ - should translate titles of toolbox entries: For a custom tool exposing two toolbox entries (`Title1`, `Title2`) and an `i18n.messages.toolNames` dictionary translating both, opening the toolbox shows the first matching popover item containing the translation of `Title1` and the last matching popover item containing the translation of `Title2`.
+ - should use capitalized tool name as translation key if toolbox title is missing: For a tool whose `toolbox.title` is an empty string, with a `toolNames` dictionary keyed on the capitalized tool name (`TestTool`), the toolbox popover item for that tool displays the dictionary's translated value, confirming the capitalized tool name is used as the fallback translation key.
+ - should translate tool name in Convert To: With a `toolNames` dictionary translating `Heading`, opening a paragraph block's Block Tunes menu and then the nested "Convert To" popover shows the header conversion option (`.ce-popover-item[data-item-name=header]` inside `.ce-popover--nested`) displaying the translated "Heading" text.
+ - Inline Toolbar
+ - should translate tool name in Convert To: With a `toolNames` dictionary translating `Heading`, selecting text in a paragraph to open the Inline Toolbar and then opening its "Convert To" nested popover shows the header option displaying the translated "Heading" text.
+
+## initialization.cy.ts
+
+- Editor basic initialization
+ - Zero-config initialization
+ - should create a visible UI: Creating an editor with an empty (zero) config renders a `div.codex-editor` element that is visible in the DOM.
+ - Configuration
+ - readOnly
+ - should create editor without editing ability when true passed: Creating the editor with `readOnly: true` results in the rendered `div.ce-paragraph` block element having a `contenteditable` attribute equal to `'false'`.
+ - style
+ - nonce
+ - should add passed nonce as attribute to editor style tag: Creating the editor with `style: { nonce: 'test-nonce' }` results in the injected `#editor-js-styles` `