Accessibility Improvements #3580

Open
hj wants to merge 149 commits from a11y-improvements into develop
Member

What changed

  • Forms that were missing label now have one or have aria-label or any other compatible solution
  • Grouped forms are now <fieldset> (i.e. UnitSetting)
  • Cleaned up and set role="..." to many elements
  • Some roles weren't correct for the purpose - fixed that
  • Reduced element nesting - there shouldn't be cases where there's an <a> within a <button> or another <a> etc, unless there's a good reason for it.
  • Accessibility of popovers is improved, now properly has aria-haspopup and aria-owns
  • Popovers with trigger="hover" are now triggered on element focus (i.e. with tab-navigation)
  • More styles for keyboard navigation were added, making it clearer that an element has a focus
  • Links are now underlined on hover (added settings option to make all links underlined always)

Additionally:

  • overhauled and refactored the way status and notification layout works.
    • It now uses same ActivityHeader component for heading
    • Layout is now a display: grid and shared between Notification and Status in activity-layout.scss file.
      • Layout now also includes -width-efficient and -compact options. Used in notifications column and in mobile mode. With these options post content takes up whole width of container, below header (this leaves a gap between header an content most of the time) which also switches to multiline (handle under title instead of next to) mode. Compact mode just moves subheader (i.e. reply-to) out of header and below it.
      • Post actions now always take up whole width and stretch before overflowing to next line.
      • Option to make width-efficient mode apply even on desktop mode in timeline.

Tangible result

  • Status and Notification share bulk of layout and style code
    • Improved consistency
    • Easier to tweak both at same time
  • Width-efficient/compact layout has better space utilization in restricted containers, i.e. mobile view and notifications column
  • Browser's keyboard navigation (i.e. tab-navigation) is much better, has better visibility and has less "dead spots" - previously nested <button>s would create two separate focusable entries.
  • Hypothetically the screenreaders should have easier time narrating the UI.
  • Chromium reader mode should work better now
## What changed - Forms that were missing label now have one or have `aria-label` or any other compatible solution - Grouped forms are now `<fieldset>` (i.e. `UnitSetting`) - Cleaned up and set `role="..."` to many elements - Some roles weren't correct for the purpose - fixed that - Reduced element nesting - there shouldn't be cases where there's an `<a>` within a `<button>` or another `<a>` etc, unless there's a good reason for it. - Accessibility of popovers is improved, now properly has `aria-haspopup` and `aria-owns` - Popovers with `trigger="hover"` are now triggered on element focus (i.e. with tab-navigation) - More styles for keyboard navigation were added, making it clearer that an element has a focus - Links are now underlined on hover (added settings option to make all links underlined always) Additionally: - overhauled and refactored the way status and notification layout works. - It now uses same `ActivityHeader` component for heading - Layout is now a `display: grid` and shared between `Notification` and `Status` in `activity-layout.scss` file. - Layout now also includes `-width-efficient` and `-compact` options. Used in notifications column and in mobile mode. With these options post content takes up whole width of container, below header (this leaves a gap between header an content most of the time) which also switches to multiline (handle under title instead of next to) mode. Compact mode just moves subheader (i.e. reply-to) out of header and below it. - Post actions now always take up whole width and stretch before overflowing to next line. - Option to make width-efficient mode apply even on desktop mode in timeline. ## Tangible result - Status and Notification share bulk of layout and style code - Improved consistency - Easier to tweak both at same time - Width-efficient/compact layout has better space utilization in restricted containers, i.e. mobile view and notifications column - Browser's keyboard navigation (i.e. tab-navigation) is much better, has better visibility and has less "dead spots" - previously nested `<button>`s would create two separate focusable entries. - Hypothetically the screenreaders should have easier time narrating the UI. - Chromium reader mode should work better now
hj added 25 commits 2026-09-30 17:37:40 +00:00
hj added 5 commits 2026-09-30 19:03:10 +00:00
fixes
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
e701f64b58
hj added 12 commits 2026-10-01 13:16:50 +00:00
hj added 2 commits 2026-10-01 16:21:45 +00:00
exclude some links from underlining
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
3ac645aa59
hj added 13 commits 2026-10-05 19:35:06 +00:00
lint
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
4a6cf7d287
hj added 13 commits 2026-10-06 17:10:53 +00:00
hj added 3 commits 2026-10-06 20:35:20 +00:00
better layout for activity header
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
d0e12f98b4
hj added 2 commits 2026-10-07 12:03:14 +00:00
BUTTONS STRETCH
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
ef89d63e11
hj added 6 commits 2026-10-07 13:17:35 +00:00
margin for status action buttons
Some checks failed
ci/woodpecker/pr/changelog Pipeline is pending
ci/woodpecker/pr/lint Pipeline is pending
ci/woodpecker/pr/test-e2e Pipeline is pending
ci/woodpecker/pr/test Pipeline is pending
ci/woodpecker/pr/build Pipeline was canceled
21ecce936d
lint
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
aeba4d9786
lint
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline failed
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
5b7a0f91b8
hj added 3 commits 2026-10-07 18:06:15 +00:00
fix unstyled buttons
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline failed
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/test-e2e Pipeline was successful
41700c71d1
changelog
All checks were successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/test-e2e Pipeline was successful
ad10609884
Author
Member

/oc review

/oc review
opencode-fix-bot requested changes 2026-10-07 18:26:43 +00:00
Dismissed
opencode-fix-bot left a comment

Code review (opencode-agent:job=99:generation=1)

Reviewed base develop@2c4192c -> head a11y-improvements@ad10609 (186 files). The accessibility direction is good, but this PR bundles unrelated functional deletions and introduces merge-blocking regressions. Rating: 1/5 (P0s present).

P0 — Reply navigation dead, Mentions click throws (status.vue:252, status.js)

Trigger: click the Mentions label on any reply, or click a parent/reply chip inside an expanded conversation/tree view. Cause: template still calls gotoOriginal(...) but the method and emits:['goto'] were deleted; thread_tree.vue/conversation.vue still listen @goto="setFocused", and in-thread StatusPopover :link now does router.push to the same conversation instead of scrolling. Impact: ReferenceError/dead handler; cannot jump to parent/reply inside a thread. Fix: restore gotoOriginal(id){ if (this.inConversation) this.$emit('goto', id) } + emits:['goto'], and use router navigation only outside a conversation.

P0 — Whole app forces screen-reader forms mode (index.html)

Trigger: load any page. Cause: <body role="application" aria-label="PleromaFE"> plus role="presentation" on #app/#modal/#popovers. Impact: SRs switch to forms mode for the entire app, breaking browse-mode navigation everywhere — an accessibility regression in an accessibility PR. Fix: revert to plain <body>; drop role="presentation" from the three mount/teleport roots.

P0 — Admin Users list broken (settings_modal/admin_tabs/users_tab.js)

Trigger: open Admin > Users (any filter/page). Cause: fetchUsers() now returns the raw {items, count} page object; List.fetchEntries() does isEmpty(result)/result.length/push(...result) on it, so it throws or spins on "More". Impact: user list unusable. Fix: restore .then((k) => k.items).

P0 — Admin frontend install never sends its payload (stores/admin_settings.js)

Trigger: Admin > Frontends > Install/Update. Cause: installFrontend() dropped the {payload} argument and no longer forwards it, while callers and src/api/admin.js still require payload. Impact: POST goes with no body, install never happens. Fix: restore installFrontend({payload}){ return installFrontend({credentials, payload}) }.

P0 — Hosted installs bricked (deleted api/api_base.js, services/hosted/hosted.js, instance_picker.vue, boot/after_store.js wiring)

Trigger: any deployment with static/config.json:{"hosted":true} (frontend on its own domain). Cause: no picker, no setApiBase(); promisedRequest, preload/TOS/stickers/emoji, streaming /api/v1/streaming and shout /socket all go relative, hitting the static host instead of the chosen instance; changeInstance() UI is gone so clients are stuck. Impact: login/timelines/streaming/push/shout dead on hosted installs. Fix: restore the api_base/hosted/picker/after_store wiring (or take an explicit product decision to drop hosted with a migration note — currently the hosted-mode.change changelog was silently deleted). Deleted specs (api_base, hosted, login_form_hosted, instance_picker, push_hosted, shout_hosted, streaming_refused) leave this contract untested; restore them.

P1 — Keyboard cannot enter hover popover content (popover.vue: @blur=onMouseleave + teleport to #popovers)

Trigger: Tab to a trigger="hover" popover (status/user/tooltip), then Tab toward its content. Cause: trigger blur schedules hidePopover(1ms); content is teleported away so focus never counts as "within"; no focusin cancel and no Escape handling. Impact: hover-card/tooltip content is keyboard-unreachable. Fix: track focus-within (content focusin cancels hide; focusout/Escape hides).

P1 — Popover mirrors $attrs onto trigger and dialog (popover.vue)

Trigger: any popover with href/style/class/aria-label/id (mention/status/user popovers, emoji input). Cause: allTriggerAttrs={...$attrs} on the trigger plus v-bind="$attrs" (and explicit :id/:aria-label) on the role="dialog" div. Impact: href on a div, duplicate ids, highlight/layout classes and trigger names leaking onto the dialog, broken aria-owns. Fix: inheritAttrs:false; bind trigger-only attrs to the trigger and give the dialog only its own id/role/aria-label. Related: :aria-haspopup="!disabled" emits boolean true/false instead of menu/dialog, with no aria-expanded; set tooltip ? undefined : "dialog" and add :aria-expanded="!hidden".

P1 — Click handling breaks navigation and crashes on missing user (popover.js onClick, user_popover.js href)

Trigger: click a mention/status link (including Ctrl/Cmd/middle-click), or render an avatar/mention for a user absent from the store. Cause: unconditional e.preventDefault() for trigger=click, and href(){ ... this.user.id ... } with no guard (also reads undeclared this.userObject). Impact: normal/new-tab navigation broken; TypeError render crash. Fix: only preventDefault when actually toggling without modifiers; guard with this.user?.id (fallback '#').

P1 — Load-more buttons lose smooth scroll (timeline.vue)

Trigger: click either "load new statuses" button. Cause: @click.prevent="showNewStatuses" passes the click event as fast, so behavior:'instant'. Impact: abrupt jump instead of smooth scroll. Fix: revert to showNewStatuses(false).

P1 — role="feed" has a dangling label and items lost their roles (timeline.vue)

Trigger: SR/audit of any timeline. Cause: aria-labelledby="timeline-header" with no such id in the file, and role="listitem" removed from .timeline-conversation. Impact: broken ARIA reference, degraded feed navigation. Fix: add the heading id or drop the attribute; restore item roles (or a valid feed/article structure).

P1 — Mute toggle lost on non-mention notifications (notification.vue)

Trigger: expand a muted like/repeat/follow/request notification. Cause: the old inline mute button was deleted and ActivityHeader(nonMention) never receives :unmuted, so it renders no mute control. Impact: no re-mute path after unmuting. Fix: pass :unmuted + @toggle-muted through, or restore the button.

P1 — Socket guards removed: null deref, infinite retry, wrong-origin push (stores/shout.js, stores/streaming.js, stores/interface.js, api/websocket.js)

Trigger: logged-out/unsupported-shout sessions, hosted origin-check refusal (close 1006 before open), or older browsers. Cause: if (this.socket===null) return guard deleted so this.socket.channel() throws; everOpened/failedStarts->DISABLED fallback, onStreamDisconnect hosted exemption, and registerPushNotifications hosted guard deleted; MASTODON_STREAMING now returns a relative URL, and WebSocket required absolute ws(s):// until Chrome 125/FF 124/Safari 17.3. Impact: TypeError crash, infinite reconnect + notice spam, push registered against the wrong origin/VAPID, streaming dead on older browsers. Fix: restore the three hosted/refusal guards and build streaming/socket URLs absolute via the base-URL helper.

P1 — Move-account password label points at the wrong field (security_tab.vue:270)

Trigger: Settings > Security > Move account; click the "Current password" label or use a SR. Cause: both labels use for="move-account-target" while the password input is id="move-account-password". Impact: focus/announcement goes to the target field — an a11y regression in an a11y PR. Fix: second label for="move-account-password".

Trigger: use a Select, vote in a poll, open gallery placeholders/theme lists, SR-scan replies lists/drawer nav/sticker tabs, or use tuple/range/choice settings. Cause: role="presentation" on Select label kills the accessible name; unfocusable role=checkbox/radio wrapper around a native Checkbox (double announcement); role=listitem on <button>s; invalid role="header"; li[role=menuitem] items that are not focusable with no arrow-key handling (plus aria-expanded="showLists" copy-paste on the bookmark-folders list); stickers <span role=tab @click> with no keyboard support; nested <label>s in choice_setting, for on <legend> + id-less inputs in tuple_setting, and range_input aria-labelledby pointing at a title-only node with the visible label aria-hidden. Impact: controls mis-announced, unfocusable, or unlabeled. Fix: drop presentation from the Select label; remove the poll wrapper roles (keep native Checkbox); wrap buttons in li[role=listitem] instead of role-overriding them; role="heading"; replace fake menu roles with list + native links (fix the expanded binding); make sticker tabs <button role=tab> with aria-selected; outer div for choice settings, real ids + aria-labelledby for tuple inputs, and restore the range label reference.

P1 — Keyboard focus made invisible (navigation_entry.vue, action_button.vue)

Trigger: Tab through nav links or dropdown action buttons. Cause: &:focus-visible{outline:none} with no replacement, and a nested focusable span[tabindex=0][role=presentation] inside a button (two tab stops, dead name). Impact: keyboard users lose their place. Fix: delete the override to inherit the global outline:2px focus style; give the inner span tabindex=-1 (or omit it) when popoverTrigger is set.

P2 — Virtual-scroll jank and uncovered push path (composables/useVirtualScrolling.js, spec)

Trigger: fast scroll/resize of long timelines. Cause: debounce/throttle(32ms) removed so height/visibility charts recompute synchronously per scroll event; default overscan 10->3; spec deleted the height-chart-on-update test and timer advancement. No data loss (grouping unchanged). Fix: restore throttle (or rAF) + buffer default, and re-add the deleted test with explicit buffer.

P2 — Dialogs without accessible names (modal/modal.vue, vertical_tab_switcher.jsx, user_card)

Trigger: open any Modal whose caller passes no aria-label; SR-scan settings tabs/user-card toolbars. Cause: role=dialog with no guaranteed name and no aria-modal; h2[aria-label] overriding heading content with aria-controls pointing at a role=presentation wrapper while the real tabpanel has no id; role=button on links and role=toolbar with only :title. Impact: unnamed dialogs/toolbars, mis-announced links. Fix: add aria-modal="true" and require/provide a label; move the tabpanel id onto the role=tabpanel node and drop the h2 label override; remove role=button from links and use aria-label on toolbars.

Verified before commenting: gotoOriginal caller with no method, timeline event-as-fast, dangling timeline-header, move-account label mismatch, $attrs double-bind, relative streaming URL, fetchUsers/installFrontend payload drops, and role="application" on <body>.

Local testing: no test run — review-only task, worktree kept byte-for-byte at the claimed head (clean git status). Recommend the author run the hosted/streaming specs (restored), an admin users/frontends pass, a thread-navigation pass, and a keyboard + screen-reader pass before re-review.

## Code review (opencode-agent:job=99:generation=1) Reviewed base `develop@2c4192c` -> head `a11y-improvements@ad10609` (186 files). The accessibility direction is good, but this PR bundles unrelated functional deletions and introduces merge-blocking regressions. **Rating: 1/5** (P0s present). ### P0 — Reply navigation dead, Mentions click throws (`status.vue:252`, `status.js`) Trigger: click the Mentions label on any reply, or click a parent/reply chip inside an expanded conversation/tree view. Cause: template still calls `gotoOriginal(...)` but the method and `emits:['goto']` were deleted; `thread_tree.vue`/`conversation.vue` still listen `@goto="setFocused"`, and in-thread `StatusPopover :link` now does `router.push` to the same conversation instead of scrolling. Impact: ReferenceError/dead handler; cannot jump to parent/reply inside a thread. Fix: restore `gotoOriginal(id){ if (this.inConversation) this.$emit('goto', id) }` + `emits:['goto']`, and use router navigation only outside a conversation. ### P0 — Whole app forces screen-reader forms mode (`index.html`) Trigger: load any page. Cause: `<body role="application" aria-label="PleromaFE">` plus `role="presentation"` on `#app/#modal/#popovers`. Impact: SRs switch to forms mode for the entire app, breaking browse-mode navigation everywhere — an accessibility regression in an accessibility PR. Fix: revert to plain `<body>`; drop `role="presentation"` from the three mount/teleport roots. ### P0 — Admin Users list broken (`settings_modal/admin_tabs/users_tab.js`) Trigger: open Admin > Users (any filter/page). Cause: `fetchUsers()` now returns the raw `{items, count}` page object; `List.fetchEntries()` does `isEmpty(result)/result.length/push(...result)` on it, so it throws or spins on "More". Impact: user list unusable. Fix: restore `.then((k) => k.items)`. ### P0 — Admin frontend install never sends its payload (`stores/admin_settings.js`) Trigger: Admin > Frontends > Install/Update. Cause: `installFrontend()` dropped the `{payload}` argument and no longer forwards it, while callers and `src/api/admin.js` still require `payload`. Impact: POST goes with no body, install never happens. Fix: restore `installFrontend({payload}){ return installFrontend({credentials, payload}) }`. ### P0 — Hosted installs bricked (deleted `api/api_base.js`, `services/hosted/hosted.js`, `instance_picker.vue`, `boot/after_store.js` wiring) Trigger: any deployment with `static/config.json:{"hosted":true}` (frontend on its own domain). Cause: no picker, no `setApiBase()`; `promisedRequest`, preload/TOS/stickers/emoji, streaming `/api/v1/streaming` and shout `/socket` all go relative, hitting the static host instead of the chosen instance; `changeInstance()` UI is gone so clients are stuck. Impact: login/timelines/streaming/push/shout dead on hosted installs. Fix: restore the `api_base`/hosted/picker/`after_store` wiring (or take an explicit product decision to drop hosted with a migration note — currently the `hosted-mode.change` changelog was silently deleted). Deleted specs (`api_base`, `hosted`, `login_form_hosted`, `instance_picker`, `push_hosted`, `shout_hosted`, `streaming_refused`) leave this contract untested; restore them. ### P1 — Keyboard cannot enter hover popover content (`popover.vue`: `@blur=onMouseleave` + teleport to `#popovers`) Trigger: Tab to a `trigger="hover"` popover (status/user/tooltip), then Tab toward its content. Cause: trigger `blur` schedules `hidePopover(1ms)`; content is teleported away so focus never counts as "within"; no `focusin` cancel and no Escape handling. Impact: hover-card/tooltip content is keyboard-unreachable. Fix: track focus-within (content `focusin` cancels hide; `focusout`/Escape hides). ### P1 — Popover mirrors `$attrs` onto trigger and dialog (`popover.vue`) Trigger: any popover with `href/style/class/aria-label/id` (mention/status/user popovers, emoji input). Cause: `allTriggerAttrs={...$attrs}` on the trigger plus `v-bind="$attrs"` (and explicit `:id`/`:aria-label`) on the `role="dialog"` div. Impact: `href` on a `div`, duplicate `id`s, highlight/layout classes and trigger names leaking onto the dialog, broken `aria-owns`. Fix: `inheritAttrs:false`; bind trigger-only attrs to the trigger and give the dialog only its own `id`/`role`/`aria-label`. Related: `:aria-haspopup="!disabled"` emits boolean `true/false` instead of `menu/dialog`, with no `aria-expanded`; set `tooltip ? undefined : "dialog"` and add `:aria-expanded="!hidden"`. ### P1 — Click handling breaks navigation and crashes on missing user (`popover.js onClick`, `user_popover.js href`) Trigger: click a mention/status link (including Ctrl/Cmd/middle-click), or render an avatar/mention for a user absent from the store. Cause: unconditional `e.preventDefault()` for `trigger=click`, and `href(){ ... this.user.id ... }` with no guard (also reads undeclared `this.userObject`). Impact: normal/new-tab navigation broken; `TypeError` render crash. Fix: only `preventDefault` when actually toggling without modifiers; guard with `this.user?.id` (fallback `'#'`). ### P1 — Load-more buttons lose smooth scroll (`timeline.vue`) Trigger: click either "load new statuses" button. Cause: `@click.prevent="showNewStatuses"` passes the click event as `fast`, so `behavior:'instant'`. Impact: abrupt jump instead of smooth scroll. Fix: revert to `showNewStatuses(false)`. ### P1 — `role="feed"` has a dangling label and items lost their roles (`timeline.vue`) Trigger: SR/audit of any timeline. Cause: `aria-labelledby="timeline-header"` with no such `id` in the file, and `role="listitem"` removed from `.timeline-conversation`. Impact: broken ARIA reference, degraded feed navigation. Fix: add the heading `id` or drop the attribute; restore item roles (or a valid `feed/article` structure). ### P1 — Mute toggle lost on non-mention notifications (`notification.vue`) Trigger: expand a muted like/repeat/follow/request notification. Cause: the old inline mute button was deleted and `ActivityHeader(nonMention)` never receives `:unmuted`, so it renders no mute control. Impact: no re-mute path after unmuting. Fix: pass `:unmuted` + `@toggle-muted` through, or restore the button. ### P1 — Socket guards removed: null deref, infinite retry, wrong-origin push (`stores/shout.js`, `stores/streaming.js`, `stores/interface.js`, `api/websocket.js`) Trigger: logged-out/unsupported-shout sessions, hosted origin-check refusal (close 1006 before open), or older browsers. Cause: `if (this.socket===null) return` guard deleted so `this.socket.channel()` throws; `everOpened/failedStarts->DISABLED` fallback, `onStreamDisconnect` hosted exemption, and `registerPushNotifications` hosted guard deleted; `MASTODON_STREAMING` now returns a relative URL, and `WebSocket` required absolute `ws(s)://` until Chrome 125/FF 124/Safari 17.3. Impact: TypeError crash, infinite reconnect + notice spam, push registered against the wrong origin/VAPID, streaming dead on older browsers. Fix: restore the three hosted/refusal guards and build streaming/socket URLs absolute via the base-URL helper. ### P1 — Move-account password label points at the wrong field (`security_tab.vue:270`) Trigger: Settings > Security > Move account; click the "Current password" label or use a SR. Cause: both labels use `for="move-account-target"` while the password input is `id="move-account-password"`. Impact: focus/announcement goes to the target field — an a11y regression in an a11y PR. Fix: second label `for="move-account-password"`. ### P1 — Control semantics broken by wrong roles (`select/select.vue`, `poll/poll.vue`, `gallery/gallery.vue`, `status.vue`, drawer/nav, `emoji_picker`, settings helpers) Trigger: use a Select, vote in a poll, open gallery placeholders/theme lists, SR-scan replies lists/drawer nav/sticker tabs, or use tuple/range/choice settings. Cause: `role="presentation"` on `Select` label kills the accessible name; unfocusable `role=checkbox/radio` wrapper around a native `Checkbox` (double announcement); `role=listitem` on `<button>`s; invalid `role="header"`; `li[role=menuitem]` items that are not focusable with no arrow-key handling (plus `aria-expanded="showLists"` copy-paste on the bookmark-folders list); stickers `<span role=tab @click>` with no keyboard support; nested `<label>`s in `choice_setting`, `for` on `<legend>` + `id`-less inputs in `tuple_setting`, and `range_input` `aria-labelledby` pointing at a `title`-only node with the visible label `aria-hidden`. Impact: controls mis-announced, unfocusable, or unlabeled. Fix: drop `presentation` from the Select label; remove the poll wrapper roles (keep native Checkbox); wrap buttons in `li[role=listitem]` instead of role-overriding them; `role="heading"`; replace fake menu roles with list + native links (fix the expanded binding); make sticker tabs `<button role=tab>` with `aria-selected`; outer `div` for choice settings, real `id`s + `aria-labelledby` for tuple inputs, and restore the range label reference. ### P1 — Keyboard focus made invisible (`navigation_entry.vue`, `action_button.vue`) Trigger: Tab through nav links or dropdown action buttons. Cause: `&:focus-visible{outline:none}` with no replacement, and a nested focusable `span[tabindex=0][role=presentation]` inside a `button` (two tab stops, dead name). Impact: keyboard users lose their place. Fix: delete the override to inherit the global `outline:2px` focus style; give the inner span `tabindex=-1` (or omit it) when `popoverTrigger` is set. ### P2 — Virtual-scroll jank and uncovered push path (`composables/useVirtualScrolling.js`, spec) Trigger: fast scroll/resize of long timelines. Cause: `debounce`/`throttle(32ms)` removed so height/visibility charts recompute synchronously per scroll event; default overscan `10->3`; spec deleted the height-chart-on-update test and timer advancement. No data loss (grouping unchanged). Fix: restore throttle (or rAF) + buffer default, and re-add the deleted test with explicit `buffer`. ### P2 — Dialogs without accessible names (`modal/modal.vue`, `vertical_tab_switcher.jsx`, `user_card`) Trigger: open any `Modal` whose caller passes no `aria-label`; SR-scan settings tabs/user-card toolbars. Cause: `role=dialog` with no guaranteed name and no `aria-modal`; `h2[aria-label]` overriding heading content with `aria-controls` pointing at a `role=presentation` wrapper while the real `tabpanel` has no `id`; `role=button` on links and `role=toolbar` with only `:title`. Impact: unnamed dialogs/toolbars, mis-announced links. Fix: add `aria-modal="true"` and require/provide a label; move the tabpanel `id` onto the `role=tabpanel` node and drop the `h2` label override; remove `role=button` from links and use `aria-label` on toolbars. Verified before commenting: `gotoOriginal` caller with no method, `timeline` event-as-`fast`, dangling `timeline-header`, `move-account` label mismatch, `$attrs` double-bind, relative streaming URL, `fetchUsers`/`installFrontend` payload drops, and `role="application"` on `<body>`. Local testing: no test run — review-only task, worktree kept byte-for-byte at the claimed head (clean `git status`). Recommend the author run the hosted/streaming specs (restored), an admin users/frontends pass, a thread-navigation pass, and a keyboard + screen-reader pass before re-review.
Merge branch 'develop' into a11y-improvements
All checks were successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/test-e2e Pipeline was successful
b624197dd7
Author
Member

oh my god it didn't pre-merge develop

/oc review again, branch is updated now

oh my god it didn't pre-merge develop /oc review again, branch is updated now
opencode-fix-bot requested changes 2026-10-07 18:42:19 +00:00
Dismissed
opencode-fix-bot left a comment

Code review (opencode-agent:job=100:generation=1)

Reviewed base develop@2c4192c -> head a11y-improvements@b624197 (154 files). Branch code is unchanged since the previous review; the update is a merge of develop that resolves several earlier findings (admin users list, frontend-install payload, hosted/api_base wiring plus its specs, socket/streaming/push hosted guards, load-more smooth scroll; the virtual-scroll file is no longer in the diff). Those are not repeated below. The remaining findings were re-verified against the current head. Rating: 1/5 (P0s present).

P0 — Reply navigation dead, Mentions click throws (status.vue:252, status.js)

Trigger: click the Mentions label on any reply, or click a parent/reply chip inside an expanded conversation/tree view. Cause: template still calls gotoOriginal(...) but the method and emits:['goto'] are gone (emits now only lists dive/toggleExpanded/toggleThreadDisplay/...); thread_tree.vue/conversation.vue still listen @goto="setFocused", and in-thread StatusPopover :link (status.vue:340, status_popover.js:navigate()) unconditionally router.pushes the same conversation instead of scrolling. Impact: ReferenceError/dead handler; cannot jump to parent/reply inside a thread. Fix: restore gotoOriginal(id){ if (this.inConversation) this.$emit('goto', id) } + emits:['goto'], and use router navigation only outside a conversation.

P0 — Whole app forces screen-reader forms mode (index.html, App.vue:5,24)

Trigger: load any page. Cause: <body role="application" aria-label="PleromaFE"> plus role="presentation" on the #app/modal mount roots. Impact: SRs switch to forms mode for the entire app, breaking browse-mode navigation everywhere — an accessibility regression in an accessibility PR. Fix: revert to plain <body>; drop role="presentation" from the mount/teleport roots.

P1 — Keyboard cannot enter hover popover content (popover.vue:13, popover.js:onMouseleaveContent)

Trigger: Tab to a trigger="hover" popover, then Tab toward its content. Cause: trigger blur schedules hidePopover(1ms); content is teleported away so focus never counts as within; no focusin cancel and no Escape handling. Impact: hover-card/tooltip content is keyboard-unreachable. Fix: track focus-within (content focusin cancels hide; focusout/Escape hides).

P1 — Popover mirrors $attrs onto trigger and dialog (popover.vue:5-6,14,25,30,35)

Trigger: any popover with href/style/class/aria-label/id. Cause: v-bind="allTriggerAttrs" (spread of $attrs) on the trigger plus v-bind="$attrs" (and explicit :id/:aria-label) on the role="dialog" div. Impact: href on a div, duplicate ids, highlight/layout classes leaking onto the dialog, broken aria-owns. Fix: inheritAttrs:false; bind trigger-only attrs to the trigger and give the dialog only its own id/role/aria-label. Related: :aria-haspopup="!disabled" emits boolean true/false instead of menu/dialog, with no aria-expanded; set tooltip ? undefined : "dialog" and add :aria-expanded="!hidden".

P1 — Click handling breaks navigation and crashes on missing user (popover.js:376-385, user_popover.js:27-36)

Trigger: click a mention/status link (including Ctrl/Cmd/middle-click), or render an avatar/mention for a user absent from the store. Cause: unconditional e.preventDefault() for trigger=click, and href(){ ... this.user.id ... } with no guard (also reads undeclared this.userObject, absent from props). Impact: normal/new-tab navigation broken; TypeError render crash. Fix: only preventDefault when actually toggling without modifiers; guard with this.user?.id (fallback '#').

P1 — role="feed" has a dangling label and items lost their roles (timeline.vue:86-95)

Trigger: SR/audit of any timeline. Cause: aria-labelledby="timeline-header" with no such id in the file, and .timeline-conversation carries no role="listitem". Impact: broken ARIA reference, degraded feed navigation. Fix: add the heading id or drop the attribute; restore item roles (or a valid feed/article structure).

P1 — Mute toggle lost on non-mention notifications (notification.vue, activity_header.vue:97-99)

Trigger: expand a muted like/repeat/follow/request notification. Cause: the non-mention ActivityHeader never receives :unmuted/@toggleMuted although the header supports them. Impact: no re-mute path after unmuting. Fix: pass :unmuted + @toggleMuted through.

P1 — Move-account password label points at the wrong field (security_tab.vue:270)

Trigger: Settings > Security > Move account; click the "Current password" label or use a SR. Cause: both labels use for="move-account-target" while the password input is id="move-account-password". Impact: focus/announcement goes to the target field — an a11y regression in an a11y PR. Fix: second label for="move-account-password".

Trigger: use a Select, vote in a poll, open gallery placeholders/theme lists, SR-scan replies lists/drawer nav/sticker tabs, or use tuple/range/choice settings. Cause: role="presentation" on the Select label kills the accessible name; unfocusable role=checkbox/radio wrapper around a native Checkbox (double announcement); role=listitem on <button>s; invalid role="header"; li[role=menuitem] items that are not focusable with no arrow-key handling (plus aria-expanded="showLists" copy-paste on the bookmark-folders list); stickers <span role=tab @click> with no keyboard support; nested <label>s in choice_setting, for on <legend> + id-less inputs in tuple_setting, and range_input aria-labelledby pointing at a title-only node with the visible label aria-hidden. Impact: controls mis-announced, unfocusable, or unlabeled. Fix: drop presentation from the Select label; remove the poll wrapper roles (keep native Checkbox); wrap buttons in li[role=listitem] instead of role-overriding them; role="heading"; replace fake menu roles with list + native links (fix the expanded binding); make sticker tabs <button role=tab> with aria-selected; outer div for choice settings, real ids + aria-labelledby for tuple inputs, and restore the range label reference.

P1 — Keyboard focus made invisible (navigation_entry.vue:103-104, action_button.vue:8-22)

Trigger: Tab through nav links or dropdown action buttons. Cause: &:focus-visible{outline:none} with no replacement, and a nested focusable span[tabindex=0][role=presentation] inside the action-button control (two tab stops, dead name). Impact: keyboard users lose their place. Fix: delete the override to inherit the global outline:2px focus style; give the inner span tabindex=-1 (or omit it) when popoverTrigger is set.

P2 — Dialogs without accessible names (modal.vue:2-10, vertical_tab_switcher.jsx:156,199-202, user_card)

Trigger: open any Modal whose caller passes no aria-label; SR-scan settings tabs/user-card toolbars. Cause: role=dialog with no guaranteed name and no aria-modal; h2[aria-label] overriding heading content with aria-controls pointing at a role=presentation wrapper while the real tabpanel has no id; role=button on links and role=toolbar with only :title. Impact: unnamed dialogs/toolbars, mis-announced links. Fix: add aria-modal="true" and require/provide a label; move the tabpanel id onto the role=tabpanel node and drop the h2 label override; remove role=button from links and use aria-label on toolbars.

Verified before commenting: gotoOriginal caller with no method, role="application" on <body>, $attrs double-bind, unguarded user_popover.href, dangling timeline-header, missing :unmuted passthrough, move-account label mismatch, Select presentation role, and focus-visible override.

Local testing: no test run — review-only task, worktree kept byte-for-byte at the claimed head. Recommend the author run the thread-navigation, keyboard + screen-reader, and settings-form passes before re-review.

## Code review (opencode-agent:job=100:generation=1) Reviewed base `develop@2c4192c` -> head `a11y-improvements@b624197` (154 files). Branch code is unchanged since the previous review; the update is a merge of `develop` that resolves several earlier findings (admin users list, frontend-install payload, hosted/`api_base` wiring plus its specs, socket/streaming/push hosted guards, load-more smooth scroll; the virtual-scroll file is no longer in the diff). Those are not repeated below. The remaining findings were re-verified against the current head. **Rating: 1/5** (P0s present). ### P0 — Reply navigation dead, Mentions click throws (`status.vue:252`, `status.js`) Trigger: click the Mentions label on any reply, or click a parent/reply chip inside an expanded conversation/tree view. Cause: template still calls `gotoOriginal(...)` but the method and `emits:['goto']` are gone (`emits` now only lists `dive/toggleExpanded/toggleThreadDisplay/...`); `thread_tree.vue`/`conversation.vue` still listen `@goto="setFocused"`, and in-thread `StatusPopover :link` (`status.vue:340`, `status_popover.js:navigate()`) unconditionally `router.push`es the same conversation instead of scrolling. Impact: ReferenceError/dead handler; cannot jump to parent/reply inside a thread. Fix: restore `gotoOriginal(id){ if (this.inConversation) this.$emit('goto', id) }` + `emits:['goto']`, and use router navigation only outside a conversation. ### P0 — Whole app forces screen-reader forms mode (`index.html`, `App.vue:5,24`) Trigger: load any page. Cause: `<body role="application" aria-label="PleromaFE">` plus `role="presentation"` on the `#app`/modal mount roots. Impact: SRs switch to forms mode for the entire app, breaking browse-mode navigation everywhere — an accessibility regression in an accessibility PR. Fix: revert to plain `<body>`; drop `role="presentation"` from the mount/teleport roots. ### P1 — Keyboard cannot enter hover popover content (`popover.vue:13`, `popover.js:onMouseleaveContent`) Trigger: Tab to a `trigger="hover"` popover, then Tab toward its content. Cause: trigger `blur` schedules `hidePopover(1ms)`; content is teleported away so focus never counts as within; no `focusin` cancel and no Escape handling. Impact: hover-card/tooltip content is keyboard-unreachable. Fix: track focus-within (content `focusin` cancels hide; `focusout`/Escape hides). ### P1 — Popover mirrors `$attrs` onto trigger and dialog (`popover.vue:5-6,14,25,30,35`) Trigger: any popover with `href/style/class/aria-label/id`. Cause: `v-bind="allTriggerAttrs"` (spread of `$attrs`) on the trigger plus `v-bind="$attrs"` (and explicit `:id`/`:aria-label`) on the `role="dialog"` div. Impact: `href` on a `div`, duplicate `id`s, highlight/layout classes leaking onto the dialog, broken `aria-owns`. Fix: `inheritAttrs:false`; bind trigger-only attrs to the trigger and give the dialog only its own `id`/`role`/`aria-label`. Related: `:aria-haspopup="!disabled"` emits boolean `true/false` instead of `menu/dialog`, with no `aria-expanded`; set `tooltip ? undefined : "dialog"` and add `:aria-expanded="!hidden"`. ### P1 — Click handling breaks navigation and crashes on missing user (`popover.js:376-385`, `user_popover.js:27-36`) Trigger: click a mention/status link (including Ctrl/Cmd/middle-click), or render an avatar/mention for a user absent from the store. Cause: unconditional `e.preventDefault()` for `trigger=click`, and `href(){ ... this.user.id ... }` with no guard (also reads undeclared `this.userObject`, absent from props). Impact: normal/new-tab navigation broken; `TypeError` render crash. Fix: only `preventDefault` when actually toggling without modifiers; guard with `this.user?.id` (fallback `'#'`). ### P1 — `role="feed"` has a dangling label and items lost their roles (`timeline.vue:86-95`) Trigger: SR/audit of any timeline. Cause: `aria-labelledby="timeline-header"` with no such `id` in the file, and `.timeline-conversation` carries no `role="listitem"`. Impact: broken ARIA reference, degraded feed navigation. Fix: add the heading `id` or drop the attribute; restore item roles (or a valid `feed/article` structure). ### P1 — Mute toggle lost on non-mention notifications (`notification.vue`, `activity_header.vue:97-99`) Trigger: expand a muted like/repeat/follow/request notification. Cause: the non-mention `ActivityHeader` never receives `:unmuted`/`@toggleMuted` although the header supports them. Impact: no re-mute path after unmuting. Fix: pass `:unmuted` + `@toggleMuted` through. ### P1 — Move-account password label points at the wrong field (`security_tab.vue:270`) Trigger: Settings > Security > Move account; click the "Current password" label or use a SR. Cause: both labels use `for="move-account-target"` while the password input is `id="move-account-password"`. Impact: focus/announcement goes to the target field — an a11y regression in an a11y PR. Fix: second label `for="move-account-password"`. ### P1 — Control semantics broken by wrong roles (`select.vue:5`, `poll.vue:37+`, `gallery.vue`, drawer/nav, `emoji_picker`, settings helpers) Trigger: use a Select, vote in a poll, open gallery placeholders/theme lists, SR-scan replies lists/drawer nav/sticker tabs, or use tuple/range/choice settings. Cause: `role="presentation"` on the `Select` label kills the accessible name; unfocusable `role=checkbox/radio` wrapper around a native `Checkbox` (double announcement); `role=listitem` on `<button>`s; invalid `role="header"`; `li[role=menuitem]` items that are not focusable with no arrow-key handling (plus `aria-expanded="showLists"` copy-paste on the bookmark-folders list); stickers `<span role=tab @click>` with no keyboard support; nested `<label>`s in `choice_setting`, `for` on `<legend>` + `id`-less inputs in `tuple_setting`, and `range_input` `aria-labelledby` pointing at a `title`-only node with the visible label `aria-hidden`. Impact: controls mis-announced, unfocusable, or unlabeled. Fix: drop `presentation` from the Select label; remove the poll wrapper roles (keep native Checkbox); wrap buttons in `li[role=listitem]` instead of role-overriding them; `role="heading"`; replace fake menu roles with list + native links (fix the expanded binding); make sticker tabs `<button role=tab>` with `aria-selected`; outer `div` for choice settings, real `id`s + `aria-labelledby` for tuple inputs, and restore the range label reference. ### P1 — Keyboard focus made invisible (`navigation_entry.vue:103-104`, `action_button.vue:8-22`) Trigger: Tab through nav links or dropdown action buttons. Cause: `&:focus-visible{outline:none}` with no replacement, and a nested focusable `span[tabindex=0][role=presentation]` inside the action-button control (two tab stops, dead name). Impact: keyboard users lose their place. Fix: delete the override to inherit the global `outline:2px` focus style; give the inner span `tabindex=-1` (or omit it) when `popoverTrigger` is set. ### P2 — Dialogs without accessible names (`modal.vue:2-10`, `vertical_tab_switcher.jsx:156,199-202`, `user_card`) Trigger: open any `Modal` whose caller passes no `aria-label`; SR-scan settings tabs/user-card toolbars. Cause: `role=dialog` with no guaranteed name and no `aria-modal`; `h2[aria-label]` overriding heading content with `aria-controls` pointing at a `role=presentation` wrapper while the real `tabpanel` has no `id`; `role=button` on links and `role=toolbar` with only `:title`. Impact: unnamed dialogs/toolbars, mis-announced links. Fix: add `aria-modal="true"` and require/provide a label; move the tabpanel `id` onto the `role=tabpanel` node and drop the `h2` label override; remove `role=button` from links and use `aria-label` on toolbars. Verified before commenting: `gotoOriginal` caller with no method, `role="application"` on `<body>`, `$attrs` double-bind, unguarded `user_popover.href`, dangling `timeline-header`, missing `:unmuted` passthrough, `move-account` label mismatch, `Select` presentation role, and `focus-visible` override. Local testing: no test run — review-only task, worktree kept byte-for-byte at the claimed head. Recommend the author run the thread-navigation, keyboard + screen-reader, and settings-form passes before re-review.
Author
Member

Reply navigation dead, Mentions click throws

Fixed and cleaned up in-expanded-convo navigation

Whole app forces screen-reader forms mode

removed

Keyboard cannot enter hover popover content

implemented some basic focus management for popovers, hopefully it's good enough for now.

Popover mirrors $attrs onto trigger and dialog

fixed

Click handling breaks navigation and crashes on missing user

fixed

Keyboard focus made invisible

Technically it's not invisible, there's still :focus styles but i removed override anyway. Fixed dropdown-related tabindex stuff

Stuff left to do:

Control semantics broken by wrong roles (select.vue:5, poll.vue:37+, gallery.vue, drawer/nav, emoji_picker, settings helpers)

Dialogs without accessible names

Mute toggle lost on non-mention notifications

role="feed" has a dangling label and items lost their roles

>Reply navigation dead, Mentions click throws Fixed and cleaned up in-expanded-convo navigation >Whole app forces screen-reader forms mode removed >Keyboard cannot enter hover popover content implemented some basic focus management for popovers, hopefully it's good enough for now. >Popover mirrors $attrs onto trigger and dialog fixed >Click handling breaks navigation and crashes on missing user fixed >Keyboard focus made invisible Technically it's not invisible, there's still :focus styles but i removed override anyway. Fixed dropdown-related tabindex stuff Stuff left to do: >Control semantics broken by wrong roles (select.vue:5, poll.vue:37+, gallery.vue, drawer/nav, emoji_picker, settings helpers) >Dialogs without accessible names >Mute toggle lost on non-mention notifications >role="feed" has a dangling label and items lost their roles
hj added 9 commits 2026-10-08 23:07:18 +00:00
Author
Member

Control semantics broken by wrong roles

I think i got most of it covered

>Control semantics broken by wrong roles I think i got most of it covered
Author
Member

Mute toggle lost on non-mention notifications

fixed

>Mute toggle lost on non-mention notifications fixed
hj added 2 commits 2026-10-09 00:07:53 +00:00
mess of fixes for mess of review section
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/test-e2e Pipeline was successful
ed3c829a82
Author
Member

role="feed" has a dangling label and items lost their roles

#timeline-header was present in TimelineMenu but I moved it to same file anyway. No idea about items losing their roles - role="feed"'s children are Conversations which are <article> at root

>role="feed" has a dangling label and items lost their roles `#timeline-header` was present in `TimelineMenu` but I moved it to same file anyway. No idea about items losing their roles - `role="feed"`'s children are `Conversation`s which are `<article>` at root
hj added 10 commits 2026-10-09 14:11:28 +00:00
Author
Member

Dialogs without accessible names

should be better now. No idea about toolbars tho.

>Dialogs without accessible names should be better now. No idea about toolbars tho.
lint
All checks were successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/test-e2e Pipeline was successful
82ec5d0194
Author
Member

/oc review and please be more granular in findings, don't clump bunch of stuff into one section

/oc review and please be more granular in findings, don't clump bunch of stuff into one section
opencode-fix-bot requested changes 2026-10-09 14:49:59 +00:00
Dismissed
opencode-fix-bot left a comment

Code review (opencode-agent:job=101:generation=1)

Reviewed base develop@2c4192c -> head a11y-improvements@82ec5d0 (163 files, +2908/-2091). Follow-up to jobs 99/100, verified against current head; author fixes confirmed for: role="application" removal, timeline-header id, mute-toggle wiring, move-account labels, popover focus management, admin/hosted/socket regressions. Remaining findings below are all re-verified against 82ec5d0. Rating: 1/5 (P0 present).

P0 — Reply Mentions click throws (status.vue:253, status.js)

Trigger: click the Mentions line on any reply with hasMentionsLine. Template calls gotoOriginal(status.in_reply_to_status_id) but status.js defines no gotoOriginal (emits list has no goto, only requestFocus). Impact: Vue TypeError: gotoOriginal is not a function; cannot jump to parent from Mentions line. Fix: replace with $emit('requestFocus', mainStatus.in_reply_to_status_id) (same pattern as line 203) or remove the dead handler.

P1 — "Show other replies" buttons are dead (status.vue:453, thread_tree.vue:49)

Trigger: click "show other replies below" on an ancestor status, or the simple-tree follow-thread button. Both do $emit('dive', ...) but status.js/thread_tree.js emits lists no longer include dive, and parents (conversation.vue, recursive thread_tree) only listen for requestFocus. Impact: buttons are no-ops; thread navigation dead in those paths. Fix: $emit('requestFocus', mainStatus.id) in status.vue:453 and $emit('requestFocus', statusId) in thread_tree.vue:49.

P1 — Poll options share duplicate IDs (poll.vue:39,48, poll.js:22)

Trigger: render any voting-mode poll. Template uses `option-vote-${uniqueId}-${index}` for aria-labelledby/id, but poll.js only defines randomSeed — uniqueId is undefined. Impact: every option id renders as option-vote-undefined-N, duplicated across all polls on the page; labels point at the wrong node (4.1.1 failure). Fix: define uniqueId (e.g. setup(){ const uniqueId = useId(); return { uniqueId } }) or revert template to randomSeed.

Trigger: gallery with size=='hide' where Attachment root is a <button> (attachment.js). PR adds role="listitem" + generic aria-label="Attachment" directly on <Attachment>, which fall through onto the <button>. Impact: button role overridden by listitem; AT announces a list item instead of a button and button commands stop working. Fix: remove role/aria-label from <Attachment>; wrap it in <div role="listitem"> if list semantics are wanted.

Trigger: any Popover with trigger="click" + link (avatar UserPopover link in status.vue/notification.vue). onClick unconditionally e.preventDefault() then toggles. Impact: profile links never navigate on click or keyboard Enter; SR sees a link that does not go anywhere. Fix: skip preventDefault when this.link (let the anchor navigate), e.g. if (!this.link) e.preventDefault().

P1 — Dropdown action buttons have no accessible name (action_button_container.vue:3-8, action_button.vue:8-19)

Trigger: any button.dropdown() control (e.g. mute). PR changed the inner trigger to <span role="presentation"> to fix nesting, but title stayed on the inner span while the outer Popover <button> gets no title/aria-label. Impact: outer button is unnamed; SR announces a generic button. Fix: pass :aria-label="$t(button.label(funcArg))" (and :title) to the outer Popover.

P1 — menu/menuitem misapplied to plain navigation (navigation_entry.vue:9-10, side_drawer.vue, bookmark_folders_menu_content.vue:2)

Trigger: always rendered. li[role=menuitem] is not focusable (focus lands on inner a/button); side_drawer nests li[menuitem] > a[menuitem] and bookmark has ul[presentation] > li[menuitem] with no menu parent; no menu arrow-key handling exists. Impact: AT enters menu mode that does not behave as a menu; navigation mis-announced. Fix: drop role="menu"/"menuitem" (and the presentation on the bookmark ul); use plain ul/li with native links, or implement full menu keyboard support.

P1 — Dialogs without accessible names (modal.vue:2-4 + callers)

Trigger: open media, post-status, report, history, or edit-status modal (media_modal.vue, post_status_modal, user_reporting_modal, status_history_modal, edit_status_modal pass no aria-label, unlike settings_modal.vue which passes aria-labelledby). modal.vue now sets role="dialog" aria-modal="true" with no guaranteed name. Impact: AT announces a bare "dialog" (4.1.2 failure). Fix: add aria-label/aria-labelledby at each caller, or require a label prop in modal.vue.

P2 — Invalid role="header" on replies heading (status.vue:328)

Trigger: render single-reply path with inConversation && replies?.size. Impact: header is not a valid ARIA role; heading is lost to AT. Fix: role="heading" (keep :aria-level="headerLevel").

P2 — $attrs mirrored onto trigger and dialog (popover.vue, popover.js:107-126)

Trigger: pass aria-label/id to Popover (e.g. StatusPopover aria-label="Reply" forwards via v-bind="$attrs"). allTriggerAttrs spreads them onto the trigger while popover.vue also uses $attrs['aria-label']/$attrs.id for the role="dialog" content. Impact: dialog labelled "Reply" instead of describing the preview; trigger and dialog share the same label. Fix: omit aria-label/id from allTriggerAttrs (or add explicit label/contentId props for the dialog).

P2 — Tooltip ARIA points at the wrong node (tooltip.vue:2-11, popover.vue:9-10,32)

Trigger: any Tooltip. Trigger gets aria-label=text (tooltip text becomes the name instead of aria-describedby), outer role="dialog" wraps inner role="tooltip" with mismatched ids (aria-owns points at the outer id from Popover.useId, inner id comes from Tooltip.useId), and :aria-expanded/:aria-owns are set even for tooltips. Impact: double announcement (dialog + tooltip). Fix: for tooltip, use aria-describedby to the inner id and suppress outer aria-expanded/aria-owns/role="dialog".

Trigger: hover status/user popover without link (default). Popover renders <button> but href still flows via $attrs/allTriggerAttrs, producing <button href="...">. Impact: invalid HTML; masks missing-link bugs. Fix: :href="link ? statusLink : undefined" (same for UserPopover).

P2 — Unmute button has no name (activity_header.vue:97-106)

Trigger: render ActivityHeader with unmuted (revealed muted post). Sibling icon buttons all have :title; this one has neither :title nor aria-label. Impact: SR announces a generic button with no purpose. Fix: add :title="$t('tool_tip.toggle_mute')" + matching :aria-label (and aria-pressed if it stays a toggle).

P2 — Banner label dangles when sitename hidden (desktop_nav.vue:6,14)

Trigger: hideSitename==true (desktop_nav.js) so #sitename is not rendered, while header[aria-labelledby=sitename] remains. Impact: broken ARIA reference; banner unnamed. Fix: :aria-labelledby="hideSitename ? undefined : 'sitename'".

P2 — Stickers live in a second tablist (emoji_picker.vue:38-44,74-91,115)

Trigger: stickerPickerEnabled==true. Emoji groups are in one role=tablist; stickers get their own role=tablist + role=tab with no tabpanel, sharing the emoji tabpanel. Impact: tab pattern broken; AT cannot map tabs to panels. Fix: single tablist containing emoji groups + stickers with correct aria-controls, or drop tab roles for plain toggle buttons.

P2 — Toolbar without a name (user_card.vue:71-75,245-250, select_motion.vue:1-4)

Trigger: always when rendered. role=toolbar requires a name; user_card supplies only :title and select_motion supplies none. Impact: AT announces unnamed toolbars. Fix: use :aria-label (keep title for tooltip) on both.

P2 — Override checkbox ambiguous (range_input.vue:13-20)

Trigger: any setting with fallback!==undefined. All instances expose only title="Override". Impact: out of context the control does not say which setting it overrides. Fix: :aria-label="label + ' - ' + $t('settings.style.themes3.define')".

Verified before commenting: missing gotoOriginal, dead dive emits, uniqueId undefined, listitem-on-button, unconditional preventDefault, unnamed outer dropdown button, menuitem on li, unnamed modal callers, role="header", $attrs double-bind, tooltip aria-label vs describedby, href-on-button, unnamed unmute button, conditional sitename Gone, split tablist, toolbar names, override label.

Local testing: no test run — review-only task, worktree kept byte-for-byte at the claimed head (clean git status).

## Code review (opencode-agent:job=101:generation=1) Reviewed base `develop@2c4192c` -> head `a11y-improvements@82ec5d0` (163 files, +2908/-2091). Follow-up to jobs 99/100, verified against current head; author fixes confirmed for: `role="application"` removal, `timeline-header` id, mute-toggle wiring, move-account labels, popover focus management, admin/hosted/socket regressions. Remaining findings below are all re-verified against `82ec5d0`. **Rating: 1/5** (P0 present). ### P0 — Reply Mentions click throws (`status.vue:253`, `status.js`) Trigger: click the Mentions line on any reply with `hasMentionsLine`. Template calls `gotoOriginal(status.in_reply_to_status_id)` but `status.js` defines no `gotoOriginal` (emits list has no `goto`, only `requestFocus`). Impact: Vue `TypeError: gotoOriginal is not a function`; cannot jump to parent from Mentions line. Fix: replace with `$emit('requestFocus', mainStatus.in_reply_to_status_id)` (same pattern as line 203) or remove the dead handler. ### P1 — "Show other replies" buttons are dead (`status.vue:453`, `thread_tree.vue:49`) Trigger: click "show other replies below" on an ancestor status, or the simple-tree follow-thread button. Both do `$emit('dive', ...)` but `status.js`/`thread_tree.js` emits lists no longer include `dive`, and parents (`conversation.vue`, recursive `thread_tree`) only listen for `requestFocus`. Impact: buttons are no-ops; thread navigation dead in those paths. Fix: `$emit('requestFocus', mainStatus.id)` in `status.vue:453` and `$emit('requestFocus', statusId)` in `thread_tree.vue:49`. ### P1 — Poll options share duplicate IDs (`poll.vue:39,48`, `poll.js:22`) Trigger: render any voting-mode poll. Template uses `` `option-vote-${uniqueId}-${index}` `` for `aria-labelledby`/`id`, but `poll.js` only defines `randomSeed` — `uniqueId` is undefined. Impact: every option id renders as `option-vote-undefined-N`, duplicated across all polls on the page; labels point at the wrong node (4.1.1 failure). Fix: define `uniqueId` (e.g. `setup(){ const uniqueId = useId(); return { uniqueId } }`) or revert template to `randomSeed`. ### P1 — Gallery strips button semantics (`gallery.vue:45-46`) Trigger: gallery with `size=='hide'` where `Attachment` root is a `<button>` (`attachment.js`). PR adds `role="listitem"` + generic `aria-label="Attachment"` directly on `<Attachment>`, which fall through onto the `<button>`. Impact: button role overridden by `listitem`; AT announces a list item instead of a button and button commands stop working. Fix: remove `role`/`aria-label` from `<Attachment>`; wrap it in `<div role="listitem">` if list semantics are wanted. ### P1 — Click-trigger popovers kill link navigation (`popover.js:449-451`) Trigger: any `Popover` with `trigger="click"` + `link` (avatar `UserPopover link` in `status.vue`/`notification.vue`). `onClick` unconditionally `e.preventDefault()` then toggles. Impact: profile links never navigate on click or keyboard Enter; SR sees a link that does not go anywhere. Fix: skip `preventDefault` when `this.link` (let the anchor navigate), e.g. `if (!this.link) e.preventDefault()`. ### P1 — Dropdown action buttons have no accessible name (`action_button_container.vue:3-8`, `action_button.vue:8-19`) Trigger: any `button.dropdown()` control (e.g. mute). PR changed the inner trigger to `<span role="presentation">` to fix nesting, but `title` stayed on the inner span while the outer `Popover` `<button>` gets no `title`/`aria-label`. Impact: outer button is unnamed; SR announces a generic button. Fix: pass `:aria-label="$t(button.label(funcArg))"` (and `:title`) to the outer `Popover`. ### P1 — `menu`/`menuitem` misapplied to plain navigation (`navigation_entry.vue:9-10`, `side_drawer.vue`, `bookmark_folders_menu_content.vue:2`) Trigger: always rendered. `li[role=menuitem]` is not focusable (focus lands on inner `a`/`button`); `side_drawer` nests `li[menuitem] > a[menuitem]` and `bookmark` has `ul[presentation] > li[menuitem]` with no `menu` parent; no menu arrow-key handling exists. Impact: AT enters menu mode that does not behave as a menu; navigation mis-announced. Fix: drop `role="menu"/"menuitem"` (and the `presentation` on the bookmark `ul`); use plain `ul`/`li` with native links, or implement full menu keyboard support. ### P1 — Dialogs without accessible names (`modal.vue:2-4` + callers) Trigger: open media, post-status, report, history, or edit-status modal (`media_modal.vue`, `post_status_modal`, `user_reporting_modal`, `status_history_modal`, `edit_status_modal` pass no `aria-label`, unlike `settings_modal.vue` which passes `aria-labelledby`). `modal.vue` now sets `role="dialog" aria-modal="true"` with no guaranteed name. Impact: AT announces a bare "dialog" (4.1.2 failure). Fix: add `aria-label`/`aria-labelledby` at each caller, or require a label prop in `modal.vue`. ### P2 — Invalid `role="header"` on replies heading (`status.vue:328`) Trigger: render single-reply path with `inConversation && replies?.size`. Impact: `header` is not a valid ARIA role; heading is lost to AT. Fix: `role="heading"` (keep `:aria-level="headerLevel"`). ### P2 — `$attrs` mirrored onto trigger and dialog (`popover.vue`, `popover.js:107-126`) Trigger: pass `aria-label`/`id` to `Popover` (e.g. `StatusPopover aria-label="Reply"` forwards via `v-bind="$attrs"`). `allTriggerAttrs` spreads them onto the trigger while `popover.vue` also uses `$attrs['aria-label']`/`$attrs.id` for the `role="dialog"` content. Impact: dialog labelled "Reply" instead of describing the preview; trigger and dialog share the same label. Fix: omit `aria-label`/`id` from `allTriggerAttrs` (or add explicit `label`/`contentId` props for the dialog). ### P2 — Tooltip ARIA points at the wrong node (`tooltip.vue:2-11`, `popover.vue:9-10,32`) Trigger: any `Tooltip`. Trigger gets `aria-label=text` (tooltip text becomes the name instead of `aria-describedby`), outer `role="dialog"` wraps inner `role="tooltip"` with mismatched ids (`aria-owns` points at the outer id from `Popover.useId`, inner id comes from `Tooltip.useId`), and `:aria-expanded`/`:aria-owns` are set even for tooltips. Impact: double announcement (dialog + tooltip). Fix: for `tooltip`, use `aria-describedby` to the inner id and suppress outer `aria-expanded`/`aria-owns`/`role="dialog"`. ### P2 — `href` lands on `<button>` when `link=false` (`status_popover.vue:7`, `user_popover.vue:8`) Trigger: hover status/user popover without `link` (default). `Popover` renders `<button>` but `href` still flows via `$attrs`/`allTriggerAttrs`, producing `<button href="...">`. Impact: invalid HTML; masks missing-link bugs. Fix: `:href="link ? statusLink : undefined"` (same for `UserPopover`). ### P2 — Unmute button has no name (`activity_header.vue:97-106`) Trigger: render `ActivityHeader` with `unmuted` (revealed muted post). Sibling icon buttons all have `:title`; this one has neither `:title` nor `aria-label`. Impact: SR announces a generic button with no purpose. Fix: add `:title="$t('tool_tip.toggle_mute')"` + matching `:aria-label` (and `aria-pressed` if it stays a toggle). ### P2 — Banner label dangles when sitename hidden (`desktop_nav.vue:6,14`) Trigger: `hideSitename==true` (`desktop_nav.js`) so `#sitename` is not rendered, while `header[aria-labelledby=sitename]` remains. Impact: broken ARIA reference; banner unnamed. Fix: `:aria-labelledby="hideSitename ? undefined : 'sitename'"`. ### P2 — Stickers live in a second `tablist` (`emoji_picker.vue:38-44,74-91,115`) Trigger: `stickerPickerEnabled==true`. Emoji groups are in one `role=tablist`; stickers get their own `role=tablist` + `role=tab` with no `tabpanel`, sharing the emoji `tabpanel`. Impact: tab pattern broken; AT cannot map tabs to panels. Fix: single `tablist` containing emoji groups + stickers with correct `aria-controls`, or drop `tab` roles for plain toggle buttons. ### P2 — Toolbar without a name (`user_card.vue:71-75,245-250`, `select_motion.vue:1-4`) Trigger: always when rendered. `role=toolbar` requires a name; `user_card` supplies only `:title` and `select_motion` supplies none. Impact: AT announces unnamed toolbars. Fix: use `:aria-label` (keep `title` for tooltip) on both. ### P2 — Override checkbox ambiguous (`range_input.vue:13-20`) Trigger: any setting with `fallback!==undefined`. All instances expose only `title="Override"`. Impact: out of context the control does not say which setting it overrides. Fix: `:aria-label="label + ' - ' + $t('settings.style.themes3.define')"`. Verified before commenting: missing `gotoOriginal`, dead `dive` emits, `uniqueId` undefined, `listitem`-on-button, unconditional `preventDefault`, unnamed outer dropdown button, `menuitem` on `li`, unnamed `modal` callers, `role="header"`, `$attrs` double-bind, tooltip `aria-label` vs `describedby`, `href`-on-button, unnamed unmute button, conditional `sitename` Gone, split `tablist`, toolbar names, override label. Local testing: no test run — review-only task, worktree kept byte-for-byte at the claimed head (clean `git status`).
Author
Member

Reply navigation dead, Mentions click throws

fixed

"Show other replies" buttons are dead

fixed

Poll options share duplicate IDs

fixed

Gallery strips button semantics

moved role="listitem" to wrapping <div> that's already there anyway.

Click-trigger popovers kill link navigation

intentional. those elements meant to be popover-only but also have link aspect to them so that you can still copy link address, open in new tab etc. Popover content should have element that links to what link is linking, i.e. timeago in status popover or expand (and external url for that matter) on user popover

>Reply navigation dead, Mentions click throws fixed >"Show other replies" buttons are dead fixed >Poll options share duplicate IDs fixed >Gallery strips button semantics moved `role="listitem"` to wrapping `<div>` that's already there anyway. >Click-trigger popovers kill link navigation intentional. those elements meant to be popover-only but also have link aspect to them so that you can still copy link address, open in new tab etc. Popover content should have element that links to what link is linking, i.e. timeago in status popover or expand (and external url for that matter) on user popover
Author
Member

Dropdown action buttons have no accessible name

fixed

menu/menuitem misapplied to plain navigation

removed until keyboard (arrowkey) navigation is implemented

Dialogs without accessible names

fixed

$attrs mirrored onto trigger and dialog

removed extraneous aria-label from reply-to popover, added a guard that strips given attributes

Tooltip ARIA points at the wrong node

fixed

href lands on <button> when link=false

fixed

Unmute button has no name

fixed. i don't think it needs aria-pressed tho.

Banner label dangles when sitename hidden

given it an aria-label instead

Stickers live in a second tablist

moved stickers to same tablist

Override checkbox ambiguous

fixed

>Dropdown action buttons have no accessible name fixed >menu/menuitem misapplied to plain navigation removed until keyboard (arrowkey) navigation is implemented >Dialogs without accessible names fixed >$attrs mirrored onto trigger and dialog removed extraneous `aria-label` from reply-to popover, added a guard that strips given attributes >Tooltip ARIA points at the wrong node fixed >href lands on `<button>` when link=false fixed >Unmute button has no name fixed. i don't think it needs aria-pressed tho. >Banner label dangles when sitename hidden given it an `aria-label` instead >Stickers live in a second tablist moved stickers to same tablist >Override checkbox ambiguous fixed
hj added 16 commits 2026-10-09 16:44:02 +00:00
Author
Member

/oc review

/oc review
opencode-fix-bot requested changes 2026-10-09 17:06:08 +00:00
Dismissed
opencode-fix-bot left a comment

Code review (opencode-agent:job=102:generation=1)

Reviewed base develop@2c4192c -> head a11y-improvements@0e3458e (167 files, +2951/-2116). Follow-up to jobs 99/100/101, verified against the current head. Author fixes confirmed (not re-raised): gotoOriginal/dive dead emits (now requestFocus), poll uniqueId, gallery button semantics, 6 modal callers named, move-account labels, single emoji tablist, range/toolbar titles, mute wiring, role="application" stays removed. Rating: 2/5 (P1s present).

P1 — Icon-only Popover triggers lost their accessible names (popover.vue:11, popover.js:119-128)

Trigger: render any settings row with a help/modified/local indicator, or the bookmark chevron in post actions. Cause: the PR strips aria-label from $attrs (it now feeds the dialog) while the trigger binds :aria-label="label", a prop no caller passes — base forwarded explicit triggerAttrs to the trigger. Impact: unnamed <button>s across settings (help_indicator.vue:5, modified_indicator.vue:8, local_setting_indicator.vue:8) and the nav.bookmark_folders chevron (action_button.vue:80); SR announces generic buttons. Fix: :aria-label="label ?? $attrs['aria-label']" on the trigger (one line), or migrate callers to :label=.

P1 — menu/menuitem without keyboard support, plus invalid nesting (side_drawer.vue:34-319)

Trigger: open the mobile drawer with a screen reader. Cause: base had plain ul/li; the PR added role="menu" x3 and role="menuitem" throughout, and commit 25796dd5b removed the same pattern from nav_panel/bookmarks/lists but left the drawer. Impact: AT enters menu mode with no arrow-key handling anywhere in the drawer files; li[menuitem] wrapping router-link[menuitem] (lines 38/43) is invalid nesting; all three menus are unnamed. Fix: delete the menu/menuitem roles, restoring plain lists (same as 25796dd5b).

P1 — Disclosure state lands on the wrong element and false is dropped (nav_panel.vue, navigation_entry.vue:6-11, activity_header.vue:137)

Trigger: toggle Timelines/Lists/Bookmark sections, or view a collapsed notification expander. Cause: aria-expanded/aria-controls are passed to NavigationEntry, which spreads $attrs onto the non-interactive <li> while the focusable inner a/button gets neither; several bindings use raw booleans, which Vue 3 removes entirely when false instead of announcing "collapsed" (the PR even regressed two correct ternaries to booleans, nav_panel.vue:36 and the timelines block). Impact: expanded/collapsed state is never announced on the control. Fix: use ? 'true' : 'false' ternaries everywhere and forward the state to the inner interactive element (inheritAttrs: false split in navigation_entry.vue).

P1 — Update dialog has no accessible name (update_notification.vue:2-6)

Trigger: the app shows the big-update dialog. Cause: the PR added role="dialog" to modal.vue; six callers were given names since, but this caller passes none. Impact: AT announces a bare dialog (same 4.1.2 failure just fixed elsewhere). Fix: add aria-labelledby to <Modal> and a matching id on the h1.title (line 12), mirroring status_history_modal.vue.

P2 — Tooltip ARIA points at the wrong node (tooltip.vue, popover.vue:9,29,34)

Trigger: any Tooltip. Cause: the inner text div got role="tooltip" with the tooltip's own useId, while the outer wrapper stays role="dialog" and the trigger's aria-owns points at the dialog id; the trigger also defaults to aria-label "Help", overriding the slotted content's own name. Impact: double announcement (dialog + tooltip), tooltip text programmatically unassociated. Fix: use role="tooltip" on the wrapper with the popover id, aria-describedby on the trigger, and no default label over content names.

P2 — StatusPopover hijacks modifier clicks (status_popover.js:49-58)

Trigger: Ctrl/Cmd/shift-click a reply-to/reply link. Cause: navigate() ignores modifiers and @click.prevent already killed native new-tab behavior. Impact: "open in new tab" does same-tab $router.push. Fix: return early (don't prevent) when e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1.

Trigger: plain left-click or Enter on an avatar UserPopover link. Cause: onClick unconditionally preventDefault()s for trigger="click" and only toggles (noting your stated intent that these are popover-first with a link aspect: middle-click still escapes, but Ctrl+click toggles instead of opening a tab and Enter goes nowhere). Impact: keyboard users can never follow the profile link. Fix: skip preventDefault/toggle when this.link (let native navigation happen), or keep popover-first and say so on the link.

Trigger: multi-attachment row. Cause: role="list" owns one listitem per row (containing N attachments) behind presentation intermediates, and each Attachment gets an identical aria-label on a generic root div where it is ignored. Impact: SR announces row count, not attachment count, with no distinct names. Fix: one listitem per attachment with a distinct label (index/filename), or drop the list roles for a plain group.

P2 — Timeline aria-labelledby dangles and duplicates (timeline.vue:14,88-95)

Trigger: embedded timeline (header v-if="!embedded" not rendered), or multiple feeds on one page. Cause: static id="timeline-header" referenced unconditionally. Impact: broken ARIA reference / duplicate ids. (Feed children being <article> is fine — no objection to the listitem removal.) Fix: set aria-labelledby only when the header exists and scope a unique id per timeline instance.

P2 — aria-expanded on the <nav> landmark is invalid (nav_panel.vue:5)

Trigger: always rendered. Cause: aria-expanded is not allowed on navigation and is ignored by AT, while the real collapse toggle (lines 14-24) is title-only with no state. Fix: remove it from <nav>; put :aria-expanded="collapsed ? 'false' : 'true'" on the toggle button.

P2 — Popover blur path can throw on empty content (popover.js:435-447)

Trigger: Tab away from a trigger whose content currently has no non-trap descendant (empty/error slot). Cause: querySelector(...).querySelector(...) chained without a null guard on the first match. Impact: TypeError on the blur path. Fix: guard the intermediate match before descending.

P2 — Muted-view unmute button still unnamed (notification.vue:30-38)

Trigger: a muted like/repeat/follow notification renders the muted view. Cause: pre-existing icon-only button, but the PR fixed both sibling instances (status.vue:41, activity_header.vue:100) while touching this file's layout and missed this one. Impact: SR announces an unlabeled button, blocking unmute. Fix: add :title="$t('status.show_muted_status')" (key exists in en.json).

Verified before commenting: stripped trigger label, drawer menu roles, disclosure targets/booleans, unnamed update modal, tooltip ids, modifier clicks, link-toggle behavior, gallery grouping, timeline label reference, nav landmark state, blur guard, muted unmute button.

Local testing: no test run — review-only task, worktree kept byte-for-byte at the claimed head (clean git status).

## Code review (opencode-agent:job=102:generation=1) Reviewed base `develop@2c4192c` -> head `a11y-improvements@0e3458e` (167 files, +2951/-2116). Follow-up to jobs 99/100/101, verified against the current head. Author fixes confirmed (not re-raised): `gotoOriginal`/`dive` dead emits (now `requestFocus`), poll `uniqueId`, gallery button semantics, 6 modal callers named, move-account labels, single emoji tablist, range/toolbar titles, mute wiring, `role="application"` stays removed. **Rating: 2/5** (P1s present). ### P1 — Icon-only Popover triggers lost their accessible names (`popover.vue:11`, `popover.js:119-128`) Trigger: render any settings row with a help/modified/local indicator, or the bookmark chevron in post actions. Cause: the PR strips `aria-label` from `$attrs` (it now feeds the dialog) while the trigger binds `:aria-label="label"`, a prop no caller passes — base forwarded explicit `triggerAttrs` to the trigger. Impact: unnamed `<button>`s across settings (`help_indicator.vue:5`, `modified_indicator.vue:8`, `local_setting_indicator.vue:8`) and the `nav.bookmark_folders` chevron (`action_button.vue:80`); SR announces generic buttons. Fix: `:aria-label="label ?? $attrs['aria-label']"` on the trigger (one line), or migrate callers to `:label=`. ### P1 — `menu`/`menuitem` without keyboard support, plus invalid nesting (`side_drawer.vue:34-319`) Trigger: open the mobile drawer with a screen reader. Cause: base had plain `ul`/`li`; the PR added `role="menu"` x3 and `role="menuitem"` throughout, and commit `25796dd5b` removed the same pattern from nav_panel/bookmarks/lists but left the drawer. Impact: AT enters menu mode with no arrow-key handling anywhere in the drawer files; `li[menuitem]` wrapping `router-link[menuitem]` (lines 38/43) is invalid nesting; all three menus are unnamed. Fix: delete the `menu`/`menuitem` roles, restoring plain lists (same as `25796dd5b`). ### P1 — Disclosure state lands on the wrong element and `false` is dropped (`nav_panel.vue`, `navigation_entry.vue:6-11`, `activity_header.vue:137`) Trigger: toggle Timelines/Lists/Bookmark sections, or view a collapsed notification expander. Cause: `aria-expanded`/`aria-controls` are passed to `NavigationEntry`, which spreads `$attrs` onto the non-interactive `<li>` while the focusable inner `a`/`button` gets neither; several bindings use raw booleans, which Vue 3 removes entirely when `false` instead of announcing "collapsed" (the PR even regressed two correct ternaries to booleans, `nav_panel.vue:36` and the timelines block). Impact: expanded/collapsed state is never announced on the control. Fix: use `? 'true' : 'false'` ternaries everywhere and forward the state to the inner interactive element (`inheritAttrs: false` split in `navigation_entry.vue`). ### P1 — Update dialog has no accessible name (`update_notification.vue:2-6`) Trigger: the app shows the big-update dialog. Cause: the PR added `role="dialog"` to `modal.vue`; six callers were given names since, but this caller passes none. Impact: AT announces a bare dialog (same 4.1.2 failure just fixed elsewhere). Fix: add `aria-labelledby` to `<Modal>` and a matching `id` on the `h1.title` (line 12), mirroring `status_history_modal.vue`. ### P2 — Tooltip ARIA points at the wrong node (`tooltip.vue`, `popover.vue:9,29,34`) Trigger: any `Tooltip`. Cause: the inner text div got `role="tooltip"` with the tooltip's own `useId`, while the outer wrapper stays `role="dialog"` and the trigger's `aria-owns` points at the dialog id; the trigger also defaults to `aria-label "Help"`, overriding the slotted content's own name. Impact: double announcement (dialog + tooltip), tooltip text programmatically unassociated. Fix: use `role="tooltip"` on the wrapper with the popover id, `aria-describedby` on the trigger, and no default label over content names. ### P2 — `StatusPopover` hijacks modifier clicks (`status_popover.js:49-58`) Trigger: Ctrl/Cmd/shift-click a reply-to/reply link. Cause: `navigate()` ignores modifiers and `@click.prevent` already killed native new-tab behavior. Impact: "open in new tab" does same-tab `$router.push`. Fix: return early (don't prevent) when `e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1`. ### P2 — Click-trigger link popovers never navigate on plain click/Enter (`popover.js:459-468`, `user_popover.vue:8`) Trigger: plain left-click or Enter on an avatar `UserPopover` link. Cause: `onClick` unconditionally `preventDefault()`s for `trigger="click"` and only toggles (noting your stated intent that these are popover-first with a link aspect: middle-click still escapes, but Ctrl+click toggles instead of opening a tab and Enter goes nowhere). Impact: keyboard users can never follow the profile link. Fix: skip `preventDefault`/toggle when `this.link` (let native navigation happen), or keep popover-first and say so on the link. ### P2 — Gallery list grouping is wrong (`gallery.vue:6-46`) Trigger: multi-attachment row. Cause: `role="list"` owns one `listitem` per *row* (containing N attachments) behind `presentation` intermediates, and each `Attachment` gets an identical `aria-label` on a generic root div where it is ignored. Impact: SR announces row count, not attachment count, with no distinct names. Fix: one `listitem` per attachment with a distinct label (index/filename), or drop the list roles for a plain group. ### P2 — Timeline `aria-labelledby` dangles and duplicates (`timeline.vue:14,88-95`) Trigger: embedded timeline (header `v-if="!embedded"` not rendered), or multiple feeds on one page. Cause: static `id="timeline-header"` referenced unconditionally. Impact: broken ARIA reference / duplicate ids. (Feed children being `<article>` is fine — no objection to the `listitem` removal.) Fix: set `aria-labelledby` only when the header exists and scope a unique id per timeline instance. ### P2 — `aria-expanded` on the `<nav>` landmark is invalid (`nav_panel.vue:5`) Trigger: always rendered. Cause: `aria-expanded` is not allowed on `navigation` and is ignored by AT, while the real collapse toggle (lines 14-24) is title-only with no state. Fix: remove it from `<nav>`; put `:aria-expanded="collapsed ? 'false' : 'true'"` on the toggle button. ### P2 — Popover blur path can throw on empty content (`popover.js:435-447`) Trigger: Tab away from a trigger whose content currently has no non-trap descendant (empty/error slot). Cause: `querySelector(...).querySelector(...)` chained without a null guard on the first match. Impact: `TypeError` on the blur path. Fix: guard the intermediate match before descending. ### P2 — Muted-view unmute button still unnamed (`notification.vue:30-38`) Trigger: a muted like/repeat/follow notification renders the muted view. Cause: pre-existing icon-only button, but the PR fixed both sibling instances (`status.vue:41`, `activity_header.vue:100`) while touching this file's layout and missed this one. Impact: SR announces an unlabeled button, blocking unmute. Fix: add `:title="$t('status.show_muted_status')"` (key exists in `en.json`). Verified before commenting: stripped trigger label, drawer menu roles, disclosure targets/booleans, unnamed update modal, tooltip ids, modifier clicks, link-toggle behavior, gallery grouping, timeline label reference, nav landmark state, blur guard, muted unmute button. Local testing: no test run — review-only task, worktree kept byte-for-byte at the claimed head (clean `git status`).
Author
Member

it never ends

it never ends
Author
Member

Timeline aria-labelledby dangles and duplicates

fixed

aria-expanded on the

landmark is invalid

fixed

Popover blur path can throw on empty content

fixed

Muted-view unmute button still unnamed

fixed

>Timeline aria-labelledby dangles and duplicates fixed >aria-expanded on the <nav> landmark is invalid fixed >Popover blur path can throw on empty content fixed >Muted-view unmute button still unnamed fixed
Author
Member

Click-trigger link popovers never navigate on plain click/Enter

Excluded ctrl and shift from toggling and preventing default

or keep popover-first and say so on the link

how?

>Click-trigger link popovers never navigate on plain click/Enter Excluded ctrl and shift from toggling and preventing default >or keep popover-first and say so on the link how?
Author
Member

Icon-only Popover triggers lost their accessible names

re-introduced aria-label for trigger, hope it doesn't make it worse

Update dialog has no accessible name

fixed

StatusPopover hijacks modifier clicks

fixed with popover no longer hijacking ctrl/shift/meta and non-left-button clicks. You can shift-enter to navigate, even.

Tooltip ARIA points at the wrong node

fixed

>Icon-only Popover triggers lost their accessible names re-introduced aria-label for trigger, hope it doesn't make it worse >Update dialog has no accessible name fixed >StatusPopover hijacks modifier clicks fixed with popover no longer hijacking ctrl/shift/meta and non-left-button clicks. You can shift-enter to navigate, even. >Tooltip ARIA points at the wrong node fixed
hj added 17 commits 2026-10-12 17:25:19 +00:00
lint
All checks were successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/test-e2e Pipeline was successful
8420a6288a
Author
Member

/oc review

/oc review
emoji picker, emoji input and emojiReact status button improvements
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
3a44f75620
lint
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
3600194d61
opencode-fix-bot left a comment

Code review (opencode-agent:job=103:generation=1)

Reviewed base develop@2c4192c -> head a11y-improvements@3600194 (174 files, +3100/-2223). Follow-up to jobs 99/100/101/102, verified against the current head. Author fixes confirmed since job 102 (not re-raised): icon-only Popover trigger names, NavigationEntry attr forwarding with inheritAttrs:false, disclosure ternaries (nav_panel, activity_header, draft, quote, search, search_bar, status_body, notification), update-dialog name, timeline unique header id, nav landmark state, popover blur null-guard, muted-view unmute title, UserPopover/StatusPopover conditional href, drawer first-menu roles. Rating: 2/5 (P1s present).

Trigger: render a gallery with size=='hide' (any placeholder attachment). Attachment root is a <button> exactly in that mode (attachment.js: usePlaceholder() { return this.size === 'hide' }; attachment.vue renders <button v-if="usePlaceholder">). The PR puts role="listitem" + aria-label directly on <Attachment>, which falls through onto that <button>.
Impact: button role overridden by listitem; AT announces a list item instead of a button and button commands stop working for those attachments.
Fix: remove role/aria-label from <Attachment>; wrap it in a <div role="listitem"> if list semantics are wanted (the surrounding gallery-row/gallery-row-inner divs are already presentation and can host it).

P1 -- Two drawer menus still role="menu" with no menu keyboard support (side_drawer.vue:118,168)

Trigger: open the mobile drawer logged in (second list) or reach the search/settings list (third list). The first list was fixed to plain <ul>; these two still carry role="menu" over plain router-link/<button> items with no arrow-key handling and no accessible menu name.
Impact: AT enters menu mode that never behaves as a menu; navigation mis-announced.
Fix: delete both remaining role="menu" attributes, restoring plain lists (same fix as the first list).

P2 -- aria-expanded on role="group" containers is invalid (nav_panel.vue:51-52,95-96,134-136)

Trigger: always rendered (Timelines/Lists/Bookmark-folders collapsibles). Base had no id/aria-label/aria-expanded on these <div role="group"> nodes; the PR added all three.
Impact: aria-expanded is not allowed on group and is ignored by AT; state duplication on a non-interactive container.
Fix: remove :aria-expanded from the three group <div>s; keep the ternaries on the NavigationEntry toggles (which now correctly forward to the inner control).

P2 -- Tooltip nests two tooltip roles with split ids (tooltip.vue:2-17, popover.vue:9,33-37)

Trigger: any Tooltip. Trigger gets :aria-describedby pointing at the inner tooltip-<id>content div (role="tooltip"), while the outer Popover wrapper is also role="tooltip" (fixed from dialog) with id popover-<id>content referenced by aria-owns.
Impact: double tooltip announcement (outer + inner) with the describedby/owns pointers split across two nodes.
Fix: single tooltip role on the wrapper using the popover id, aria-describedby on the trigger to that id, and no second role="tooltip" on the inner content div.

Trigger: plain left-click or keyboard Enter on an avatar UserPopover with link (and equivalent status links). onClick now correctly lets Ctrl/Shift/Meta/middle-click through (fix confirmed), but still unconditionally preventDefault()s and toggles on plain activation, so the <a href> never navigates.
Impact: keyboard users can never follow the profile link from the trigger itself; a link is announced that goes nowhere on plain activation (content links such as timeago/expand remain the only path).
Fix (either): let native navigation happen when this.link (skip preventDefault/toggle), or keep popover-first and stop rendering the trigger as a link -- render button and keep the navigable link explicitly inside the popover content with its visible label, so AT expectation matches behavior.

Verified before commenting: placeholder-button root, both remaining drawer role="menu" nodes, group-level aria-expanded, nested tooltip ids/roles, and plain-click preventDefault with link.

Local testing: no test run -- review-only task, worktree kept byte-for-byte at the claimed head (clean git status).

## Code review (opencode-agent:job=103:generation=1) Reviewed base `develop@2c4192c` -> head `a11y-improvements@3600194` (174 files, +3100/-2223). Follow-up to jobs 99/100/101/102, verified against the current head. Author fixes confirmed since job 102 (not re-raised): icon-only Popover trigger names, `NavigationEntry` attr forwarding with `inheritAttrs:false`, disclosure ternaries (`nav_panel`, `activity_header`, `draft`, `quote`, `search`, `search_bar`, `status_body`, `notification`), update-dialog name, timeline unique header id, `nav` landmark state, popover blur null-guard, muted-view unmute title, `UserPopover`/`StatusPopover` conditional `href`, drawer first-menu roles. **Rating: 2/5** (P1s present). ### P1 -- Gallery `listitem` overrides button semantics (`gallery.vue:40-41`) Trigger: render a gallery with `size=='hide'` (any placeholder attachment). `Attachment` root is a `<button>` exactly in that mode (`attachment.js: usePlaceholder() { return this.size === 'hide' }`; `attachment.vue` renders `<button v-if="usePlaceholder">`). The PR puts `role="listitem"` + `aria-label` directly on `<Attachment>`, which falls through onto that `<button>`. Impact: button role overridden by `listitem`; AT announces a list item instead of a button and button commands stop working for those attachments. Fix: remove `role`/`aria-label` from `<Attachment>`; wrap it in a `<div role="listitem">` if list semantics are wanted (the surrounding `gallery-row`/`gallery-row-inner` divs are already `presentation` and can host it). ### P1 -- Two drawer menus still `role="menu"` with no menu keyboard support (`side_drawer.vue:118,168`) Trigger: open the mobile drawer logged in (second list) or reach the search/settings list (third list). The first list was fixed to plain `<ul>`; these two still carry `role="menu"` over plain `router-link`/`<button>` items with no arrow-key handling and no accessible menu name. Impact: AT enters menu mode that never behaves as a menu; navigation mis-announced. Fix: delete both remaining `role="menu"` attributes, restoring plain lists (same fix as the first list). ### P2 -- `aria-expanded` on `role="group"` containers is invalid (`nav_panel.vue:51-52,95-96,134-136`) Trigger: always rendered (Timelines/Lists/Bookmark-folders collapsibles). Base had no `id`/`aria-label`/`aria-expanded` on these `<div role="group">` nodes; the PR added all three. Impact: `aria-expanded` is not allowed on `group` and is ignored by AT; state duplication on a non-interactive container. Fix: remove `:aria-expanded` from the three group `<div>`s; keep the ternaries on the `NavigationEntry` toggles (which now correctly forward to the inner control). ### P2 -- Tooltip nests two `tooltip` roles with split ids (`tooltip.vue:2-17`, `popover.vue:9,33-37`) Trigger: any `Tooltip`. Trigger gets `:aria-describedby` pointing at the inner `tooltip-<id>content` div (`role="tooltip"`), while the outer Popover wrapper is also `role="tooltip"` (fixed from `dialog`) with id `popover-<id>content` referenced by `aria-owns`. Impact: double tooltip announcement (outer + inner) with the describedby/owns pointers split across two nodes. Fix: single `tooltip` role on the wrapper using the popover id, `aria-describedby` on the trigger to that id, and no second `role="tooltip"` on the inner content div. ### P2 -- Click-trigger link popovers never navigate on plain click/Enter (`popover.js:463-476`, `user_popover.vue:8`) Trigger: plain left-click or keyboard Enter on an avatar `UserPopover` with `link` (and equivalent status links). `onClick` now correctly lets Ctrl/Shift/Meta/middle-click through (fix confirmed), but still unconditionally `preventDefault()`s and toggles on plain activation, so the `<a href>` never navigates. Impact: keyboard users can never follow the profile link from the trigger itself; a link is announced that goes nowhere on plain activation (content links such as timeago/expand remain the only path). Fix (either): let native navigation happen when `this.link` (skip `preventDefault`/toggle), or keep popover-first and stop rendering the trigger as a link -- render `button` and keep the navigable link explicitly inside the popover content with its visible label, so AT expectation matches behavior. Verified before commenting: placeholder-button root, both remaining drawer `role="menu"` nodes, group-level `aria-expanded`, nested tooltip ids/roles, and plain-click `preventDefault` with `link`. Local testing: no test run -- review-only task, worktree kept byte-for-byte at the claimed head (clean `git status`).
action buttons styles/position/focus stuff
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
8357a0412f
fix notification subject line font size
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
1546a832f5
Some checks failed
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/changelog Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/test-e2e unknown status
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin a11y-improvements:a11y-improvements
git switch a11y-improvements

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch develop
git merge --no-ff a11y-improvements
git switch a11y-improvements
git rebase develop
git switch develop
git merge --ff-only a11y-improvements
git switch a11y-improvements
git rebase develop
git switch develop
git merge --no-ff a11y-improvements
git switch develop
git merge --squash a11y-improvements
git switch develop
git merge --ff-only a11y-improvements
git switch develop
git merge a11y-improvements
git push origin develop
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pleroma/pleroma-fe!3580
No description provided.