Parent
#328. Related: #251
What to build
A screen-reader user types /tabel, and the Slash menu empties in silence. Command jump and the chat Mention Picker have a status; the Slash menu has none. Option ids also come from the row position, so a new first row after a filter keeps the old id and may go unread.
After the fix, "No matching blocks" is spoken once, and each option id names its row. The design system also gains a "Slash menu popup" entry that records the picker status recipe for other pickers.
Acceptance criteria
Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone. The last acceptance box is a maintainer screen-reader walk after the build.
Category: bug
Current behavior:
All slash files live in apps/webapp/src/components/TipTap/slash/.
- "No matching blocks" is a plain
div inside role="listbox", with no live region (SlashMenuList.tsx:48-49).
SlashMenuList returns null while there is no session (SlashMenuList.tsx:38). The session arrives later, in onUpdate, after items() resolves (renderSlashMenu.ts:35-46, :81).
- Option ids are
slash-menu-option-${index} (slashMenuSession.ts:6, used at SlashMenuList.tsx:23 and :57).
- Both hosts render
SlashMenuList. The desktop popup mounts through props.mount, which appends it to document.body, outside the editor (renderSlashMenu.ts:69-78). The phone sheet is SlashMenuSheet.tsx, opened with trapFocus: false (apps/webapp/src/components/BottomSheet.tsx, slashMenu entry).
Desired behavior:
SlashMenuList returns a fragment: the listbox, plus one status node as its sibling.
- Render the status node even while the session is
null. It then mounts empty in both hosts, before any text arrives. Screen readers can skip text that is present when a live region first mounts (unverified here; the maintainer walk checks it).
- The status node is
role="status" with aria-live="polite". Its text is "No matching blocks" only when a session exists with zero items. Otherwise it is empty.
- The same node is the visible text. So browse mode reads it once. Style it
text-base-content/60 px-2 py-3 text-sm only while it has text. Empty, it has no padding.
- Keep
if (!session) for the listbox. Seven Cypress checks treat a missing [data-testid="slash-menu"] as closed.
- Build option ids from
item.id, for example slash-menu-option-heading1. Compute the active id from session.items[session.selectedIndex]?.id.
- Do not add "speak only when the query changes" logic. A live region speaks only when its text changes, so a remote edit with the same result stays silent.
Where to start:
apps/webapp/src/components/TipTap/slash/SlashMenuList.tsx, and slashOptionId in apps/webapp/src/components/TipTap/slash/slashMenuSession.ts. Item ids come from slashItems.ts (heading1 … heading6, subtitle, normal, bulletList, and so on).
- Update the two id assertions in
apps/webapp/cypress/e2e/editor/slash/slash-menu.cy.ts: slash-menu-option-0 (line 81) becomes slash-menu-option-heading1, and slash-menu-option-2 (line 138) becomes slash-menu-option-heading3.
- Add the "Slash menu popup" entry to
.cursor/docs/design-system.md §Floating overlays, before "Mention picker popup". Use the same shape as that entry: a ### heading, a placement line, and a State and Recipe table.
- frame:
popoverPanelClassName plus w-auto min-w-56 overflow-y-auto overscroll-contain. Max height 320px from the size middleware (MAX_LIST_HEIGHT) in apps/webapp/src/components/TipTap/extensions/slash-menu/slash-menu.ts.
- phone: the
slashMenu sheet, title "Insert block", trapFocus: false, so the editor keeps focus and the keyboard.
- rows:
hover:bg-base-200 rounded-field min-h-11 gap-2.5 px-2.5 py-1.5 text-sm. Keyboard-selected adds bg-base-200. Icon 16px at opacity-70.
- picker status: one polite region outside the listbox and outside
.ProseMirror. It mounts empty first. One node is both the visible empty text and the status. It speaks only the empty text, never a count.
Line numbers are hints as of 2026-09-28; the agent searches by symbol.
Rules that apply:
apps/webapp/src/components/TipTap/CLAUDE.md §Editor Performance: never put a live region inside .ProseMirror.
.cursor/docs/design-system.md §State language, for the row states. AGENTS.md §UI And Theme.
AGENTS.md §Test Policy: add no new test. A text-in-a-node check proves no speech. The screen-reader walk is the proof.
Verify:
bun run check (it also runs scripts/check-agent-docs.ts over the design-system file). If Prettier flags the new table, run bunx prettier --write .cursor/docs/design-system.md. Do not run bun run format:fix; it writes every unformatted file in the repo.
- Start the local stack with
make dev-local. Note the webapp port; it is not always 3000. From apps/webapp, run bunx cypress run --spec cypress/e2e/editor/slash/slash-menu.cy.ts --env EDITOR_BASE_URL=http://localhost:<port>.
- Desktop browser, on the
/editor playground, in light and dark (set data-theme on <html>):
- Type
/zzz. "No matching blocks" shows in muted ink. In the accessibility tree, the status node is not a child of the listbox.
- Type
/. While rows show, the status node exists, is empty, and has no height.
- Type
/h, note aria-activedescendant on the editor, press Backspace once, then type b. The value changes.
- Type
/zzz, then press Enter. The caret moves to a new, empty line, and /zzz stays as text.
- Phone: a narrow window does not produce the mobile shell, and the playground has none. Use a real phone, or a mobile user agent on a real pad route. Type
/zzz and check the sheet shows the text.
- Maintainer walk (the last box): NVDA with Chrome, VoiceOver with Safari, and VoiceOver on iPhone. Type
/b, /bl, /zzz, then Backspace back to matches. Also open straight into zero rows: type /tabel , then Backspace over the space.
Out of scope
Parent
#328. Related: #251
What to build
A screen-reader user types
/tabel, and the Slash menu empties in silence. Command jump and the chat Mention Picker have a status; the Slash menu has none. Option ids also come from the row position, so a new first row after a filter keeps the old id and may go unread.After the fix, "No matching blocks" is spoken once, and each option id names its row. The design system also gains a "Slash menu popup" entry that records the picker status recipe for other pickers.
Acceptance criteria
/zzzshows "No matching blocks" in one node. That node is also the polite status region.role="listbox"and outside.ProseMirror, in the desktop popup and in the phone sheet.SlashMenuListrenders that node, empty, while the session isnull. So the node is in the DOM before the list gets its first result.item.id. Type/h, press Backspace once, then typeb: the editor'saria-activedescendantchanges value.data-testid="slash-menu"stays on the listbox only. The listbox still renders only while a session exists..cursor/docs/design-system.md§Floating overlays has a "Slash menu popup" entry with the picker status recipe.Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone. The last acceptance box is a maintainer screen-reader walk after the build.
Category: bug
Current behavior:
All slash files live in
apps/webapp/src/components/TipTap/slash/.divinsiderole="listbox", with no live region (SlashMenuList.tsx:48-49).SlashMenuListreturnsnullwhile there is no session (SlashMenuList.tsx:38). The session arrives later, inonUpdate, afteritems()resolves (renderSlashMenu.ts:35-46,:81).slash-menu-option-${index}(slashMenuSession.ts:6, used atSlashMenuList.tsx:23and:57).SlashMenuList. The desktop popup mounts throughprops.mount, which appends it todocument.body, outside the editor (renderSlashMenu.ts:69-78). The phone sheet isSlashMenuSheet.tsx, opened withtrapFocus: false(apps/webapp/src/components/BottomSheet.tsx,slashMenuentry).Desired behavior:
SlashMenuListreturns a fragment: the listbox, plus one status node as its sibling.null. It then mounts empty in both hosts, before any text arrives. Screen readers can skip text that is present when a live region first mounts (unverified here; the maintainer walk checks it).role="status"witharia-live="polite". Its text is "No matching blocks" only when a session exists with zero items. Otherwise it is empty.text-base-content/60 px-2 py-3 text-smonly while it has text. Empty, it has no padding.if (!session)for the listbox. Seven Cypress checks treat a missing[data-testid="slash-menu"]as closed.item.id, for exampleslash-menu-option-heading1. Compute the active id fromsession.items[session.selectedIndex]?.id.Where to start:
apps/webapp/src/components/TipTap/slash/SlashMenuList.tsx, andslashOptionIdinapps/webapp/src/components/TipTap/slash/slashMenuSession.ts. Item ids come fromslashItems.ts(heading1…heading6,subtitle,normal,bulletList, and so on).apps/webapp/cypress/e2e/editor/slash/slash-menu.cy.ts:slash-menu-option-0(line 81) becomesslash-menu-option-heading1, andslash-menu-option-2(line 138) becomesslash-menu-option-heading3..cursor/docs/design-system.md§Floating overlays, before "Mention picker popup". Use the same shape as that entry: a###heading, a placement line, and a State and Recipe table.popoverPanelClassNameplusw-auto min-w-56 overflow-y-auto overscroll-contain. Max height 320px from thesizemiddleware (MAX_LIST_HEIGHT) inapps/webapp/src/components/TipTap/extensions/slash-menu/slash-menu.ts.slashMenusheet, title "Insert block",trapFocus: false, so the editor keeps focus and the keyboard.hover:bg-base-200 rounded-field min-h-11 gap-2.5 px-2.5 py-1.5 text-sm. Keyboard-selected addsbg-base-200. Icon 16px atopacity-70..ProseMirror. It mounts empty first. One node is both the visible empty text and the status. It speaks only the empty text, never a count.Line numbers are hints as of 2026-09-28; the agent searches by symbol.
Rules that apply:
apps/webapp/src/components/TipTap/CLAUDE.md§Editor Performance: never put a live region inside.ProseMirror..cursor/docs/design-system.md§State language, for the row states.AGENTS.md§UI And Theme.AGENTS.md§Test Policy: add no new test. A text-in-a-node check proves no speech. The screen-reader walk is the proof.Verify:
bun run check(it also runsscripts/check-agent-docs.tsover the design-system file). If Prettier flags the new table, runbunx prettier --write .cursor/docs/design-system.md. Do not runbun run format:fix; it writes every unformatted file in the repo.make dev-local. Note the webapp port; it is not always 3000. Fromapps/webapp, runbunx cypress run --spec cypress/e2e/editor/slash/slash-menu.cy.ts --env EDITOR_BASE_URL=http://localhost:<port>./editorplayground, in light and dark (setdata-themeon<html>):/zzz. "No matching blocks" shows in muted ink. In the accessibility tree, the status node is not a child of the listbox./. While rows show, the status node exists, is empty, and has no height./h, notearia-activedescendanton the editor, press Backspace once, then typeb. The value changes./zzz, then press Enter. The caret moves to a new, empty line, and/zzzstays as text./zzzand check the sheet shows the text./b,/bl,/zzz, then Backspace back to matches. Also open straight into zero rows: type/tabel, then Backspace over the space.Out of scope