Skip to content

fix(a11y): dialog semantics, focus management, icon labels, html lang#1

Closed
JohnMcLear wants to merge 194 commits intodevelopfrom
fix/a11y-dialogs-labels-lang
Closed

fix(a11y): dialog semantics, focus management, icon labels, html lang#1
JohnMcLear wants to merge 194 commits intodevelopfrom
fix/a11y-dialogs-labels-lang

Conversation

@JohnMcLear
Copy link
Copy Markdown
Owner

Summary

Highest-impact accessibility fixes from a fresh audit of Etherpad's pad surface. Four themes:

  1. Dialog semantics — every .popup (#settings, #import_export, #connectivity, #embed, #users, #mycolorpicker, #skin-variants) now exposes role="dialog", aria-modal="true", and either aria-labelledby (when an <h1> is present) or aria-label. Fixed the invalid aria-role="document" on #otherusers (it's role, not aria-role); now role="region" + aria-live="polite" so collaborator joins/leaves get announced.

  2. Focus managementtoggleDropDown now remembers the trigger button, focuses the first focusable element inside the popup on open, and restores focus to the trigger on close. Escape while focus is inside an open popup closes it (previously did nothing — users had to click outside).

  3. Real buttons for icon-only controls

    • <div id="chaticon" onclick="…"><button type="button" id="chaticon" aria-label="Open chat">
    • <a id="titlecross" onClick="…"><button aria-label="Close chat">
    • <a id="titlesticky" onClick="…"><button aria-label="Pin chat to screen">
    • <span class="show-more-icon-btn"><button aria-label="Show more toolbar buttons" aria-expanded> (toggles aria-expanded on click)
    • All six export links (#exportetherpada#exportopena) get a descriptive aria-label; the inner icon spans get aria-hidden="true" so SR doesn't double-read.
    • Theme switcher knob: aria-label="theme-switcher-knob" (a class-style identifier) → "Toggle theme" (human text).
    • Decorative chat-bubble glyph and #chatcounter are now properly labelled (aria-hidden on the icon, aria-label="Unread messages" on the counter).
  4. <html> lang/dir negotiated per requestpad.html, index.html, timeslider.html now render with lang and dir matching the client's Accept-Language (negotiated against i18n.availableLangs), falling back to en/ltr. Client-side l10n.ts already refines both attributes after html10n loads, so this just gives screen readers a correct language hint during the brief pre-localization window.

Out of scope (deliberate)

  • Full focus-trap with Tab cycling — present popups are short, so init-focus + Escape is adequate. A library swap can come later.
  • WCAG-AA contrast pass, 44×44 touch targets, full :focus-visible sweep.
  • aria-label localization on container dialogs — Etherpad's html10n only translates a fixed attribute list (title/alt/placeholder/value/innerHTML/textContent), not aria-label. Dialog labels are English-only until html10n grows that affordance.

Test plan

  • CI: ts-check passes (modified TS files have // @ts-nocheck so no type regressions possible)
  • CI: existing Playwright suite still green (chat/userlist tests use #chaticon/#titlecross selectors which are unchanged)
  • CI: new spec tests/frontend-new/specs/a11y_dialogs.spec.ts passes — covers <html lang>, dialog ARIA, Escape-closes-and-restores-focus, export labels, chat button conversion, userlist region, show-more button
  • Manual: tab through the toolbar, open Settings, press Escape, focus returns to the cog
  • Manual: with VoiceOver/NVDA, opening Settings announces "Pad Settings dialog"

🤖 Generated with Claude Code

JohnMcLear and others added 30 commits March 4, 2026 12:07
* docs: add AGENTS.MD for AI and developer guidance

* docs: update project name from Etherpad Lite to Etherpad

* docs: fix incorrect test directory path in AGENTS.MD

* docs: correct test stack description in AGENTS.MD (Mocha is primary)

* docs: fix incorrect easysync documentation path in AGENTS.MD

* chore: add .pr_agent.toml to enable automatic PR review/description on push

* docs: remove nodejs version from README and AGENTS.MD (prefer package.json)

* docs: standardize all indentation to 2 spaces

* chore: update src/package.json node engine to match root (>=20.0.0)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](docker/login-action@v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](docker/build-push-action@v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](docker/metadata-action@v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](docker/setup-buildx-action@v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.2.1 to 8.2.2.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](express-rate-limit/express-rate-limit@v8.2.1...v8.2.2)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](docker/setup-qemu-action@v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…er#7358)

Bumps the dev-dependencies group with 16 updates:

| Package | From | To |
| --- | --- | --- |
| [@types/formidable](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/formidable) | `3.4.6` | `3.5.0` |
| [@types/jquery](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jquery) | `3.5.33` | `4.0.0` |
| [@types/jsdom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jsdom) | `27.0.0` | `28.0.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.2.3` | `25.3.5` |
| [@types/supertest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/supertest) | `6.0.3` | `7.2.0` |
| [@types/whatwg-mimetype](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/whatwg-mimetype) | `3.0.2` | `5.0.0` |
| [eslint](https://github.com/eslint/eslint) | `10.0.0` | `10.0.3` |
| [sinon](https://github.com/sinonjs/sinon) | `21.0.1` | `21.0.2` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.55.0` | `8.56.1` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.55.0` | `8.56.1` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.0` | `0.5.2` |
| [i18next](https://github.com/i18next/i18next) | `25.8.8` | `25.8.16` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.564.0` | `0.577.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.71.1` | `7.71.2` |
| [react-i18next](https://github.com/i18next/react-i18next) | `16.5.4` | `16.5.6` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.13.0` | `7.13.1` |


Updates `@types/formidable` from 3.4.6 to 3.5.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/formidable)

Updates `@types/jquery` from 3.5.33 to 4.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jquery)

Updates `@types/jsdom` from 27.0.0 to 28.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jsdom)

Updates `@types/node` from 25.2.3 to 25.3.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/supertest` from 6.0.3 to 7.2.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/supertest)

Updates `@types/whatwg-mimetype` from 3.0.2 to 5.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/whatwg-mimetype)

Updates `eslint` from 10.0.0 to 10.0.3
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](eslint/eslint@v10.0.0...v10.0.3)

Updates `sinon` from 21.0.1 to 21.0.2
- [Release notes](https://github.com/sinonjs/sinon/releases)
- [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md)
- [Commits](sinonjs/sinon@v21.0.1...v21.0.2)

Updates `@typescript-eslint/eslint-plugin` from 8.55.0 to 8.56.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.56.1/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.55.0 to 8.56.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.56.1/packages/parser)

Updates `eslint-plugin-react-refresh` from 0.5.0 to 0.5.2
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](ArnaudBarre/eslint-plugin-react-refresh@v0.5.0...v0.5.2)

Updates `i18next` from 25.8.8 to 25.8.16
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/i18next@v25.8.8...v25.8.16)

Updates `lucide-react` from 0.564.0 to 0.577.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.577.0/packages/lucide-react)

Updates `react-hook-form` from 7.71.1 to 7.71.2
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](react-hook-form/react-hook-form@v7.71.1...v7.71.2)

Updates `react-i18next` from 16.5.4 to 16.5.6
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/react-i18next@v16.5.4...v16.5.6)

Updates `react-router-dom` from 7.13.0 to 7.13.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.13.1/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: "@types/formidable"
  dependency-version: 3.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@types/jquery"
  dependency-version: 4.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: "@types/jsdom"
  dependency-version: 28.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.3.5
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@types/supertest"
  dependency-version: 7.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: "@types/whatwg-mimetype"
  dependency-version: 5.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: sinon
  dependency-version: 21.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.56.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.56.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: i18next
  dependency-version: 25.8.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: lucide-react
  dependency-version: 0.577.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.71.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-i18next
  dependency-version: 16.5.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-router-dom
  dependency-version: 7.13.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [openapi-backend](https://github.com/openapistack/openapi-backend) from 5.15.0 to 5.16.1.
- [Release notes](https://github.com/openapistack/openapi-backend/releases)
- [Commits](openapistack/openapi-backend@5.15.0...5.16.1)

---
updated-dependencies:
- dependency-name: openapi-backend
  dependency-version: 5.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [jose](https://github.com/panva/jose) from 6.1.3 to 6.2.1.
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](panva/jose@v6.1.3...v6.2.1)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [ejs](https://github.com/mde/ejs) from 4.0.1 to 5.0.1.
- [Release notes](https://github.com/mde/ejs/releases)
- [Changelog](https://github.com/mde/ejs/blob/main/RELEASE_NOTES_v4.md)
- [Commits](mde/ejs@v4.0.1...v5.0.1)

---
updated-dependencies:
- dependency-name: ejs
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [oidc-provider](https://github.com/panva/node-oidc-provider) from 9.6.1 to 9.7.0.
- [Release notes](https://github.com/panva/node-oidc-provider/releases)
- [Changelog](https://github.com/panva/node-oidc-provider/blob/main/CHANGELOG.md)
- [Commits](panva/node-oidc-provider@v9.6.1...v9.7.0)

---
updated-dependencies:
- dependency-name: oidc-provider
  dependency-version: 9.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [axios](https://github.com/axios/axios) from 1.13.5 to 1.13.6.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](axios/axios@v1.13.5...v1.13.6)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.13.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…r#7366)

Bumps the dev-dependencies group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.3.5` | `25.4.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.0.18` | `4.1.0` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.56.1` | `8.57.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.56.1` | `8.57.0` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `5.1.4` | `6.0.0` |
| [i18next](https://github.com/i18next/i18next) | `25.8.16` | `25.8.18` |
| [react-i18next](https://github.com/i18next/react-i18next) | `16.5.6` | `16.5.8` |


Updates `@types/node` from 25.3.5 to 25.4.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `vitest` from 4.0.18 to 4.1.0
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/vitest)

Updates `@typescript-eslint/eslint-plugin` from 8.56.1 to 8.57.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.57.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.56.1 to 8.57.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.57.0/packages/parser)

Updates `@vitejs/plugin-react` from 5.1.4 to 6.0.0
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.0/packages/plugin-react)

Updates `i18next` from 25.8.16 to 25.8.18
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/i18next@v25.8.16...v25.8.18)

Updates `react-i18next` from 16.5.6 to 16.5.8
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/react-i18next@v16.5.6...v16.5.8)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.57.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.57.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: i18next
  dependency-version: 25.8.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-i18next
  dependency-version: 16.5.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.3 to 0.27.4.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](evanw/esbuild@v0.27.3...v0.27.4)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.27.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.2.2 to 8.3.1.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](express-rate-limit/express-rate-limit@v8.2.2...v8.3.1)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…r#7368)

Bumps the dev-dependencies group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) and [vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy).


Updates `@types/node` from 25.4.0 to 25.5.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-react` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.1/packages/plugin-react)

Updates `vite-plugin-static-copy` from 3.2.0 to 3.3.0
- [Release notes](https://github.com/sapphi-red/vite-plugin-static-copy/releases)
- [Changelog](https://github.com/sapphi-red/vite-plugin-static-copy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sapphi-red/vite-plugin-static-copy/compare/vite-plugin-static-copy@3.2.0...vite-plugin-static-copy@3.3.0)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vite-plugin-static-copy
  dependency-version: 3.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
)

Bumps [rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible) from 9.1.1 to 10.0.1.
- [Release notes](https://github.com/animir/node-rate-limiter-flexible/releases)
- [Commits](animir/node-rate-limiter-flexible@v9.1.1...v10.0.1)

---
updated-dependencies:
- dependency-name: rate-limiter-flexible
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [jsdom](https://github.com/jsdom/jsdom) from 28.1.0 to 29.0.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/v29.0.0/Changelog.md)
- [Commits](jsdom/jsdom@v28.1.0...v29.0.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…r#7371)

Bumps the dev-dependencies group with 2 updates: [sinon](https://github.com/sinonjs/sinon) and [zustand](https://github.com/pmndrs/zustand).


Updates `sinon` from 21.0.2 to 21.0.3
- [Release notes](https://github.com/sinonjs/sinon/releases)
- [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md)
- [Commits](sinonjs/sinon@v21.0.2...v21.0.3)

Updates `zustand` from 5.0.11 to 5.0.12
- [Release notes](https://github.com/pmndrs/zustand/releases)
- [Commits](pmndrs/zustand@v5.0.11...v5.0.12)

---
updated-dependencies:
- dependency-name: sinon
  dependency-version: 21.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: zustand
  dependency-version: 5.0.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…her#7370)

Bumps [reitzig/actions-asciidoctor](https://github.com/reitzig/actions-asciidoctor) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/reitzig/actions-asciidoctor/releases)
- [Changelog](https://github.com/reitzig/actions-asciidoctor/blob/master/CHANGELOG.md)
- [Commits](reitzig/actions-asciidoctor@v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: reitzig/actions-asciidoctor
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [lru-cache](https://github.com/isaacs/node-lru-cache) from 11.2.6 to 11.2.7.
- [Changelog](https://github.com/isaacs/node-lru-cache/blob/main/CHANGELOG.md)
- [Commits](isaacs/node-lru-cache@v11.2.6...v11.2.7)

---
updated-dependencies:
- dependency-name: lru-cache
  dependency-version: 11.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…r#7376)

Bumps the dev-dependencies group with 2 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) and [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser).


Updates `@typescript-eslint/eslint-plugin` from 8.57.0 to 8.57.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.57.1/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.57.0 to 8.57.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.57.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.57.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.57.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [oidc-provider](https://github.com/panva/node-oidc-provider) from 9.7.0 to 9.7.1.
- [Release notes](https://github.com/panva/node-oidc-provider/releases)
- [Changelog](https://github.com/panva/node-oidc-provider/blob/main/CHANGELOG.md)
- [Commits](panva/node-oidc-provider@v9.7.0...v9.7.1)

---
updated-dependencies:
- dependency-name: oidc-provider
  dependency-version: 9.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…r#7381)

Bumps the dev-dependencies group with 2 updates: [i18next](https://github.com/i18next/i18next) and [vite-plugin-babel](https://github.com/owlsdepartment/vite-plugin-babel).


Updates `i18next` from 25.8.18 to 25.8.19
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](i18next/i18next@v25.8.18...v25.8.19)

Updates `vite-plugin-babel` from 1.5.1 to 1.6.0
- [Commits](https://github.com/owlsdepartment/vite-plugin-babel/commits)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.8.19
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vite-plugin-babel
  dependency-version: 1.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [jose](https://github.com/panva/jose) from 6.2.1 to 6.2.2.
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](panva/jose@v6.2.1...v6.2.2)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
@JohnMcLear
Copy link
Copy Markdown
Owner Author

/review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 23, 2026

Code Review by Qodo

🐞 Bugs (7) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Wrong focus restore target🐞 Bug ≡ Correctness
Description
toggleDropDown() only captures _lastTrigger when no popup is open, but switching from one popup
to another happens within a single toggleDropDown(moduleName) call (closing one and opening
another), so _lastTrigger stays set to the original trigger and focus restoration after closing
returns to the wrong toolbar control.
Code

src/static/js/pad_editbar.ts[R222-227]

+      // Remember the trigger so we can restore focus when the dialog closes.
+      const wasAnyOpen = $('.popup.popup-show').length > 0;
+      if (!wasAnyOpen && moduleName !== 'none') {
+        const active = document.activeElement;
+        if (active && active !== document.body) this._lastTrigger = active;
+      }
Evidence
toggleDropDown() sets _lastTrigger only if wasAnyOpen is false. When switching popups,
wasAnyOpen is true, and the function closes the currently-open module and opens the new one in the
same loop, so _lastTrigger is not updated to the new trigger. When the user later closes popups,
focus restoration uses the stale _lastTrigger.

src/static/js/pad_editbar.ts[210-279]
src/static/js/pad_editbar.ts[247-262]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`toggleDropDown()` stores `_lastTrigger` only when transitioning from “no popup open” to “popup open”. If a user switches directly from one popup to another (within the same call), `_lastTrigger` is not updated, so focus restoration after closing returns to the *previous* trigger.
### Issue Context
Switching popups is handled in the `moduleName !== 'none'` branch by removing `popup-show` from the currently open module(s) and adding it to the new one inside the same loop.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[210-279]
### Suggested fix
Capture the trigger whenever a popup is about to be opened, even if another popup is currently open. Practical options:
1) If `document.activeElement` is a usable trigger, always set `_lastTrigger = document.activeElement` when `moduleName !== 'none'`.
2) If `document.activeElement` is `body` or inside a popup, fall back to the corresponding toolbar button, e.g. `document.querySelector(`li[data-key='${moduleName}'] button`)`.
3) Clear/replace `_lastTrigger` on every open so focus restoration reflects the most recent trigger.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Escape doesn't close users🐞 Bug ≡ Correctness
Description
The new Escape handler calls toggleDropDown('none'), but toggleDropDown('none') explicitly skips
the users dropdown, so pressing Escape inside the Users popup does not close it.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
_bodyKeyEvent() now intercepts Escape when focus is inside any open .popup.popup-show and
invokes toggleDropDown('none'). However, toggleDropDown('none') continues early for
thisModuleName === 'users', so #users is never closed by Escape.

src/static/js/pad_editbar.ts[329-356]
src/static/js/pad_editbar.ts[231-246]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape-to-close is implemented by calling `toggleDropDown('none')`, but the `none` branch skips closing the `users` popup. As a result, Escape does nothing for the Users dialog.
### Issue Context
`toggleDropDown('none')` currently contains `if (thisModuleName === 'users') continue;`.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[231-246]
- src/static/js/pad_editbar.ts[329-336]
### Suggested fix
Make Escape close `#users` as well (unless it is intentionally sticky):
- Remove the unconditional skip for `users`, OR
- Only skip `users` if it is in a sticky state (e.g. `#users.hasClass('stickyUsers')`), OR
- Add a dedicated “close all popups” helper that includes users and call that from the Escape handler.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Chat label overridden by l10n🐞 Bug ≡ Correctness
Description
#chaticon has an explicit aria-label="Open chat" and title="Chat (Alt C)", but adding
data-l10n-id="pad.chat.title" causes html10n to overwrite both title and aria-label with the
localized string at runtime, making the label inconsistent and potentially breaking tests that
assert the initial aria-label.
Code

src/templates/pad.html[R383-387]

+        <button type="button" id="chaticon" class="visible" title="Chat (Alt C)" aria-label="Open chat" data-l10n-id="pad.chat.title">
       <span id="chatlabel" data-l10n-id="pad.chat"></span>
-            <span class="buttonicon buttonicon-chat"></span>
-            <span id="chatcounter">0</span>
-        </div>
+            <span class="buttonicon buttonicon-chat" aria-hidden="true"></span>
+            <span id="chatcounter" aria-label="Unread messages">0</span>
+        </button>
Evidence
Because the l10n key ends in .title, html10n treats it as a translatable title attribute and
(per its implementation) also sets aria-label to the same translated string whenever it applies a
translation to any non-textContent property. This will overwrite the hard-coded aria-label/title
in the markup.

src/templates/pad.html[383-387]
src/static/js/vendors/html10n.ts[643-667]
src/locales/en.json[160-160]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`#chaticon` sets `aria-label` and `title` in HTML, but also adds `data-l10n-id="pad.chat.title"`. html10n will overwrite the element’s `title` and `aria-label` after localization.
### Issue Context
In html10n, IDs that end with `.title` are treated as a `title` translation, and the translator sets `aria-label` to the same translation string.
### Fix Focus Areas
- src/templates/pad.html[383-387]
- src/static/js/vendors/html10n.ts[643-667]
### Suggested fix
Pick a single source of truth:
- Simplest: remove `data-l10n-id="pad.chat.title"` from `#chaticon` and keep the explicit `title`/`aria-label`.
- Or: remove the hard-coded `title`/`aria-label` and update the l10n string to include the shortcut text (and adjust tests to expect the localized value).
Avoid having both hard-coded attributes and a `data-l10n-id` that can overwrite them.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Show-more button unstyled🐞 Bug ⚙ Maintainability
Description
.show-more-icon-btn was changed from a ` to a `, but the toolbar CSS does not reset native
button styles (background/border/appearance), which can cause visible styling/layout regressions
across browsers.
Code

src/templates/pad.html[77]

+      <button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
Evidence
The template now renders a ` for .show-more-icon-btn, but existing CSS for .toolbar
.show-more-icon-btn` only sets sizing/typography and does not clear user-agent button styles
(border, background, padding, appearance).

src/templates/pad.html[74-77]
src/static/css/pad/toolbar.css[100-110]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A `<span>` was converted to a `<button>`, but the CSS does not reset default button styles, which can cause borders/background/padding to appear.
### Issue Context
The toolbar CSS styles `.show-more-icon-btn` as if it were a neutral inline element.
### Fix Focus Areas
- src/static/css/pad/toolbar.css[100-110]
- src/templates/pad.html[74-77]
### Suggested fix
Add a reset for `.toolbar .show-more-icon-btn` (and optionally focus-visible styling), e.g.:
- `background: transparent; border: 0; padding: 0; color: inherit; font: inherit; appearance: none;`
Keep the existing width/height/line-height so the `:after` icon stays centered.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Tabnabbing via export links 🐞 Bug ⛨ Security
Description
The export links use target="_blank" but still lack rel="noopener noreferrer", which keeps
window.opener accessible to the opened page and enables reverse-tabnabbing if that page executes
script.
Code

src/templates/pad.html[R218-221]

+              <a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad">
+                <span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad" aria-hidden="true"></span>
         </a>
-              <a id="exporthtmla" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
+              <a id="exporthtmla" target="_blank" class="exportlink" aria-label="Export as HTML">
Evidence
All export links open in a new tab but do not set rel="noopener noreferrer". The same file already
demonstrates the safer pattern (rel="noopener") for external links, indicating the intended
convention.

src/templates/pad.html[218-235]
src/templates/pad.html[183-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Links with `target="_blank"` should include `rel="noopener noreferrer"` to prevent reverse-tabnabbing.
### Issue Context
The export links (`#export*`) use `target="_blank"` and no `rel`.
### Fix Focus Areas
- src/templates/pad.html[218-235]
### Suggested fix
Add `rel="noopener noreferrer"` to each export `<a ... target="_blank" ...>`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (18)
6. Escape won’t close userlist🐞 Bug ≡ Correctness
Description
The new Escape path calls toggleDropDown('none'), but toggleDropDown('none') intentionally skips the
'users' dropdown and #users is not a .toolbar-popup element. Escape pressed while focus is inside
the Users popup therefore won’t close it or restore focus to the trigger.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
Escape handling always routes to toggleDropDown('none') when focus is inside an open popup, but
toggleDropDown('none') skips closing the users dropdown. Because the Users popup is rendered as
class="popup" (no toolbar-popup class), the earlier $('.toolbar-popup').removeClass('popup-show')
line does not affect it, leaving it open on Escape.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[219-246]
src/templates/pad.html[348-376]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape-to-close calls `toggleDropDown('none')`, but that code path skips closing the `users` dropdown, so Escape won’t dismiss the Users popup.
### Issue Context
- `toggleDropDown('none')` currently has a hard-coded `if (thisModuleName === 'users') continue;`.
- `#users` in the template has `class="popup"` (no `toolbar-popup`), so `$('.toolbar-popup').removeClass('popup-show')` does not close it.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[211-246]
- src/static/js/pad_editbar.ts[329-336]
- src/templates/pad.html[348-376]
### Suggested fix
- In the `moduleName === 'none'` branch, close `users` unless it is sticky:
- Replace the unconditional skip with something like:
- `if (thisModuleName === 'users' && $('#users').hasClass('stickyUsers')) continue;`
- otherwise remove `popup-show` from `#users` the same way as other dropdowns.
- Alternatively, in the Escape handler, detect `#users.popup-show` and explicitly close it (again respecting `stickyUsers`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Show-more button default styling🐞 Bug ⚙ Maintainability
Description
The show-more control changed from a span to a button, but there is no CSS reset for native button
border/background/padding. When it becomes visible in cropped-toolbar mode, it can render with
browser-default button chrome and spacing.
Code

src/templates/pad.html[77]

+      <button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
Evidence
The template now emits a ` for .show-more-icon-btn`. The existing CSS sets sizing/typography but
does not reset default button styles (border/background/padding/appearance), which were irrelevant
when the element was a ``.

src/templates/pad.html[64-78]
src/static/css/pad/toolbar.css[100-110]
src/static/skins/colibris/src/components/toolbar.css[116-124]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`.show-more-icon-btn` is now a `<button>` and may pick up browser default border/background/padding unless explicitly reset.
### Issue Context
The existing toolbar CSS styles `.show-more-icon-btn` (size, line-height, etc.) but does not neutralize native button UI. Previously this was a `<span>` so it had no native chrome.
### Fix Focus Areas
- src/templates/pad.html[64-78]
- src/static/css/pad/toolbar.css[100-110]
- src/static/skins/colibris/src/components/toolbar.css[116-124]
### Suggested fix
Add a reset for `.toolbar .show-more-icon-btn` (in both the default toolbar CSS and colibris skin if needed), for example:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Blank-target export lacks noopener 🐞 Bug ⛨ Security
Description
The export links still use target="_blank" without rel="noopener noreferrer", allowing the opened
page to access window.opener. This enables tabnabbing/opener navigation if the opened document
executes script.
Code

src/templates/pad.html[R218-221]

+              <a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad">
+                <span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad" aria-hidden="true"></span>
        </a>
-              <a id="exporthtmla" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
+              <a id="exporthtmla" target="_blank" class="exportlink" aria-label="Export as HTML">
Evidence
The PR touched the export link markup and kept target="_blank" but did not add `rel="noopener
noreferrer". The codebase already uses rel="noopener" on other target="_blank"` links,
indicating this protection is expected.

src/templates/pad.html[218-235]
src/node/handler/ExportHandler.ts[58-87]
src/templates/pad.html[183-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`target="_blank"` links without `rel="noopener noreferrer"` expose `window.opener` to the new tab.
### Issue Context
The export links in the import/export dialog open in a new tab and were modified in this PR. Other links in the same template already include `rel="noopener"`, so adding it here is consistent.
### Fix Focus Areas
- src/templates/pad.html[218-235]
### Suggested fix
Add `rel="noopener noreferrer"` to each export `<a ... target="_blank" ...>` element (all six). If you need referrer behavior, keep it consistent with existing link policies.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Escape won’t close userlist🐞 Bug ≡ Correctness
Description
The new Escape path calls toggleDropDown('none'), but toggleDropDown('none') intentionally skips the
'users' dropdown and #users is not a .toolbar-popup element. Escape pressed while focus is inside
the Users popup therefore won’t close it or restore focus to the trigger.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
Escape handling always routes to toggleDropDown('none') when focus is inside an open popup, but
toggleDropDown('none') skips closing the users dropdown. Because the Users popup is rendered as
class="popup" (no toolbar-popup class), the earlier $('.toolbar-popup').removeClass('popup-show')
line does not affect it, leaving it open on Escape.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[219-246]
src/templates/pad.html[348-376]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape-to-close calls `toggleDropDown('none')`, but that code path skips closing the `users` dropdown, so Escape won’t dismiss the Users popup.
### Issue Context
- `toggleDropDown('none')` currently has a hard-coded `if (thisModuleName === 'users') continue;`.
- `#users` in the template has `class="popup"` (no `toolbar-popup`), so `$('.toolbar-popup').removeClass('popup-show')` does not close it.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[211-246]
- src/static/js/pad_editbar.ts[329-336]
- src/templates/pad.html[348-376]
### Suggested fix
- In the `moduleName === 'none'` branch, close `users` unless it is sticky:
- Replace the unconditional skip with something like:
- `if (thisModuleName === 'users' && $('#users').hasClass('stickyUsers')) continue;`
- otherwise remove `popup-show` from `#users` the same way as other dropdowns.
- Alternatively, in the Escape handler, detect `#users.popup-show` and explicitly close it (again respecting `stickyUsers`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Show-more button default styling🐞 Bug ⚙ Maintainability
Description
The show-more control changed from a span to a button, but there is no CSS reset for native button
border/background/padding. When it becomes visible in cropped-toolbar mode, it can render with
browser-default button chrome and spacing.
Code

src/templates/pad.html[77]

+      <button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
Evidence
The template now emits a ` for .show-more-icon-btn`. The existing CSS sets sizing/typography but
does not reset default button styles (border/background/padding/appearance), which were irrelevant
when the element was a ``.

src/templates/pad.html[64-78]
src/static/css/pad/toolbar.css[100-110]
src/static/skins/colibris/src/components/toolbar.css[116-124]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`.show-more-icon-btn` is now a `<button>` and may pick up browser default border/background/padding unless explicitly reset.
### Issue Context
The existing toolbar CSS styles `.show-more-icon-btn` (size, line-height, etc.) but does not neutralize native button UI. Previously this was a `<span>` so it had no native chrome.
### Fix Focus Areas
- src/templates/pad.html[64-78]
- src/static/css/pad/toolbar.css[100-110]
- src/static/skins/colibris/src/components/toolbar.css[116-124]
### Suggested fix
Add a reset for `.toolbar .show-more-icon-btn` (in both the default toolbar CSS and colibris skin if needed), for example:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Blank-target export lacks noopener 🐞 Bug ⛨ Security
Description
The export links still use target="_blank" without rel="noopener noreferrer", allowing the opened
page to access window.opener. This enables tabnabbing/opener navigation if the opened document
executes script.
Code

src/templates/pad.html[R218-221]

+              <a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad">
+                <span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad" aria-hidden="true"></span>
         </a>
-              <a id="exporthtmla" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
+              <a id="exporthtmla" target="_blank" class="exportlink" aria-label="Export as HTML">
Evidence
The PR touched the export link markup and kept target="_blank" but did not add `rel="noopener
noreferrer". The codebase already uses rel="noopener" on other target="_blank"` links,
indicating this protection is expected.

src/templates/pad.html[218-235]
src/node/handler/ExportHandler.ts[58-87]
src/templates/pad.html[183-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`target="_blank"` links without `rel="noopener noreferrer"` expose `window.opener` to the new tab.
### Issue Context
The export links in the import/export dialog open in a new tab and were modified in this PR. Other links in the same template already include `rel="noopener"`, so adding it here is consistent.
### Fix Focus Areas
- src/templates/pad.html[218-235]
### Suggested fix
Add `rel="noopener noreferrer"` to each export `<a ... target="_blank" ...>` element (all six). If you need referrer behavior, keep it consistent with existing link policies.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Escape won’t close userlist🐞 Bug ≡ Correctness
Description
The new Escape path calls toggleDropDown('none'), but toggleDropDown('none') intentionally skips the
'users' dropdown and #users is not a .toolbar-popup element. Escape pressed while focus is inside
the Users popup therefore won’t close it or restore focus to the trigger.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
Escape handling always routes to toggleDropDown('none') when focus is inside an open popup, but
toggleDropDown('none') skips closing the users dropdown. Because the Users popup is rendered as
class="popup" (no toolbar-popup class), the earlier $('.toolbar-popup').removeClass('popup-show')
line does not affect it, leaving it open on Escape.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[219-246]
src/templates/pad.html[348-376]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape-to-close calls `toggleDropDown('none')`, but that code path skips closing the `users` dropdown, so Escape won’t dismiss the Users popup.
### Issue Context
- `toggleDropDown('none')` currently has a hard-coded `if (thisModuleName === 'users') continue;`.
- `#users` in the template has `class="popup"` (no `toolbar-popup`), so `$('.toolbar-popup').removeClass('popup-show')` does not close it.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[211-246]
- src/static/js/pad_editbar.ts[329-336]
- src/templates/pad.html[348-376]
### Suggested fix
- In the `moduleName === 'none'` branch, close `users` unless it is sticky:
- Replace the unconditional skip with something like:
- `if (thisModuleName === 'users' && $('#users').hasClass('stickyUsers')) continue;`
- otherwise remove `popup-show` from `#users` the same way as other dropdowns.
- Alternatively, in the Escape handler, detect `#users.popup-show` and explicitly close it (again respecting `stickyUsers`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Show-more button default styling🐞 Bug ⚙ Maintainability
Description
The show-more control changed from a span to a button, but there is no CSS reset for native button
border/background/padding. When it becomes visible in cropped-toolbar mode, it can render with
browser-default button chrome and spacing.
Code

src/templates/pad.html[77]

+      <button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
Evidence
The template now emits a ` for .show-more-icon-btn`. The existing CSS sets sizing/typography but
does not reset default button styles (border/background/padding/appearance), which were irrelevant
when the element was a ``.

src/templates/pad.html[64-78]
src/static/css/pad/toolbar.css[100-110]
src/static/skins/colibris/src/components/toolbar.css[116-124]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`.show-more-icon-btn` is now a `<button>` and may pick up browser default border/background/padding unless explicitly reset.
### Issue Context
The existing toolbar CSS styles `.show-more-icon-btn` (size, line-height, etc.) but does not neutralize native button UI. Previously this was a `<span>` so it had no native chrome.
### Fix Focus Areas
- src/templates/pad.html[64-78]
- src/static/css/pad/toolbar.css[100-110]
- src/static/skins/colibris/src/components/toolbar.css[116-124]
### Suggested fix
Add a reset for `.toolbar .show-more-icon-btn` (in both the default toolbar CSS and colibris skin if needed), for example:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. Blank-target export lacks noopener 🐞 Bug ⛨ Security
Description
The export links still use target="_blank" without rel="noopener noreferrer", allowing the opened
page to access window.opener. This enables tabnabbing/opener navigation if the opened document
executes script.
Code

src/templates/pad.html[R218-221]

+              <a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad">
+                <span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad" aria-hidden="true"></span>
          </a>
-              <a id="exporthtmla" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
+              <a id="exporthtmla" target="_blank" class="exportlink" aria-label="Export as HTML">
Evidence
The PR touched the export link markup and kept target="_blank" but did not add `rel="noopener
noreferrer". The codebase already uses rel="noopener" on other target="_blank"` links,
indicating this protection is expected.

src/templates/pad.html[218-235]
src/node/handler/ExportHandler.ts[58-87]
src/templates/pad.html[183-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`target="_blank"` links without `rel="noopener noreferrer"` expose `window.opener` to the new tab.
### Issue Context
The export links in the import/export dialog open in a new tab and were modified in this PR. Other links in the same template already include `rel="noopener"`, so adding it here is consistent.
### Fix Focus Areas
- src/templates/pad.html[218-235]
### Suggested fix
Add `rel="noopener noreferrer"` to each export `<a ... target="_blank" ...>` element (all six). If you need referrer behavior, keep it consistent with existing link policies.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. Escape won’t close userlist🐞 Bug ≡ Correctness
Description
The new Escape path calls toggleDropDown('none'), but toggleDropDown('none') intentionally skips the
'users' dropdown and #users is not a .toolbar-popup element. Escape pressed while focus is inside
the Users popup therefore won’t close it or restore focus to the trigger.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
Escape handling always routes to toggleDropDown('none') when focus is inside an open popup, but
toggleDropDown('none') skips closing the users dropdown. Because the Users popup is rendered as
class="popup" (no toolbar-popup class), the earlier $('.toolbar-popup').removeClass('popup-show')
line does not affect it, leaving it open on Escape.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[219-246]
src/templates/pad.html[348-376]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape-to-close calls `toggleDropDown('none')`, but that code path skips closing the `users` dropdown, so Escape won’t dismiss the Users popup.
### Issue Context
- `toggleDropDown('none')` currently has a hard-coded `if (thisModuleName === 'users') continue;`.
- `#users` in the template has `class="popup"` (no `toolbar-popup`), so `$('.toolbar-popup').removeClass('popup-show')` does not close it.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[211-246]
- src/static/js/pad_editbar.ts[329-336]
- src/templates/pad.html[348-376]
### Suggested fix
- In the `moduleName === 'none'` branch, close `users` unless it is sticky:
- Replace the unconditional skip with something like:
- `if (thisModuleName === 'users' && $('#users').hasClass('stickyUsers')) continue;`
- otherwise remove `popup-show` from `#users` the same way as other dropdowns.
- Alternatively, in the Escape handler, detect `#users.popup-show` and explicitly close it (again respecting `stickyUsers`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. Show-more button default styling🐞 Bug ⚙ Maintainability
Description
The show-more control changed from a span to a button, but there is no CSS reset for native button
border/background/padding. When it becomes visible in cropped-toolbar mode, it can render with
browser-default button chrome and spacing.
Code

src/templates/pad.html[77]

+      <button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
Evidence
The template now emits a ` for .show-more-icon-btn`. The existing CSS sets sizing/typography but
does not reset default button styles (border/background/padding/appearance), which were irrelevant
when the element was a ``.

src/templates/pad.html[64-78]
src/static/css/pad/toolbar.css[100-110]
src/static/skins/colibris/src/components/toolbar.css[116-124]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`.show-more-icon-btn` is now a `<button>` and may pick up browser default border/background/padding unless explicitly reset.
### Issue Context
The existing toolbar CSS styles `.show-more-icon-btn` (size, line-height, etc.) but does not neutralize native button UI. Previously this was a `<span>` so it had no native chrome.
### Fix Focus Areas
- src/templates/pad.html[64-78]
- src/static/css/pad/toolbar.css[100-110]
- src/static/skins/colibris/src/components/toolbar.css[116-124]
### Suggested fix
Add a reset for `.toolbar .show-more-icon-btn` (in both the default toolbar CSS and colibris skin if needed), for example:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


17. Blank-target export lacks noopener 🐞 Bug ⛨ Security
Description
The export links still use target="_blank" without rel="noopener noreferrer", allowing the opened
page to access window.opener. This enables tabnabbing/opener navigation if the opened document
executes script.
Code

src/templates/pad.html[R218-221]

+              <a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad">
+                <span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad" aria-hidden="true"></span>
           </a>
-              <a id="exporthtmla" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
+              <a id="exporthtmla" target="_blank" class="exportlink" aria-label="Export as HTML">
Evidence
The PR touched the export link markup and kept target="_blank" but did not add `rel="noopener
noreferrer". The codebase already uses rel="noopener" on other target="_blank"` links,
indicating this protection is expected.

src/templates/pad.html[218-235]
src/node/handler/ExportHandler.ts[58-87]
src/templates/pad.html[183-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`target="_blank"` links without `rel="noopener noreferrer"` expose `window.opener` to the new tab.
### Issue Context
The export links in the import/export dialog open in a new tab and were modified in this PR. Other links in the same template already include `rel="noopener"`, so adding it here is consistent.
### Fix Focus Areas
- src/templates/pad.html[218-235]
### Suggested fix
Add `rel="noopener noreferrer"` to each export `<a ... target="_blank" ...>` element (all six). If you need referrer behavior, keep it consistent with existing link policies.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


18. Escape won’t close userlist🐞 Bug ≡ Correctness
Description
The new Escape path calls toggleDropDown('none'), but toggleDropDown('none') intentionally skips the
'users' dropdown and #users is not a .toolbar-popup element. Escape pressed while focus is inside
the Users popup therefore won’t close it or restore focus to the trigger.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
Escape handling always routes to toggleDropDown('none') when focus is inside an open popup, but
toggleDropDown('none') skips closing the users dropdown. Because the Users popup is rendered as
class="popup" (no toolbar-popup class), the earlier $('.toolbar-popup').removeClass('popup-show')
line does not affect it, leaving it open on Escape.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[219-246]
src/templates/pad.html[348-376]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape-to-close calls `toggleDropDown('none')`, but that code path skips closing the `users` dropdown, so Escape won’t dismiss the Users popup.
### Issue Context
- `toggleDropDown('none')` currently has a hard-coded `if (thisModuleName === 'users') continue;`.
- `#users` in the template has `class="popup"` (no `toolbar-popup`), so `$('.toolbar-popup').removeClass('popup-show')` does not close it.
### Fix Focus Areas
- src/static/js/pad_editbar.ts[211-246]
- src/static/js/pad_editbar.ts[329-336]
- src/templates/pad.html[348-376]
### Suggested fix
- In the `moduleName === 'none'` branch, close `users` unless it is sticky:
- Replace the unconditional skip with something like:
 - `if (thisModuleName === 'users' && $('#users').hasClass('stickyUsers')) continue;`
 - otherwise remove `popup-show` from `#users` the same way as other dropdowns.
- Alternatively, in the Escape handler, detect `#users.popup-show` and explicitly close it (again respecting `stickyUsers`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


19. Show-more button default styling🐞 Bug ⚙ Maintainability
Description
The show-more control changed from a span to a button, but there is no CSS reset for native button
border/background/padding. When it becomes visible in cropped-toolbar mode, it can render with
browser-default button chrome and spacing.
Code

src/templates/pad.html[77]

+      <button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
Evidence
The template now emits a ` for .show-more-icon-btn`. The existing CSS sets sizing/typography but
does not reset default button styles (border/background/padding/appearance), which were irrelevant
when the element was a ``.

src/templates/pad.html[64-78]
src/static/css/pad/toolbar.css[100-110]
src/static/skins/colibris/src/components/toolbar.css[116-124]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`.show-more-icon-btn` is now a `<button>` and may pick up browser default border/background/padding unless explicitly reset.
### Issue Context
The existing toolbar CSS styles `.show-more-icon-btn` (size, line-height, etc.) but does not neutralize native button UI. Previously this was a `<span>` so it had no native chrome.
### Fix Focus Areas
- src/templates/pad.html[64-78]
- src/static/css/pad/toolbar.css[100-110]
- src/static/skins/colibris/src/components/toolbar.css[116-124]
### Suggested fix
Add a reset for `.toolbar .show-more-icon-btn` (in both the default toolbar CSS and colibris skin if needed), for example:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


20. Blank-target export lacks noopener 🐞 Bug ⛨ Security
Description
The export links still use target="_blank" without rel="noopener noreferrer", allowing the opened
page to access window.opener. This enables tabnabbing/opener navigation if the opened document
executes script.
Code

src/templates/pad.html[R218-221]

+              <a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad">
+                <span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad" aria-hidden="true"></span>
            </a>
-              <a id="exporthtmla" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
+              <a id="exporthtmla" target="_blank" class="exportlink" aria-label="Export as HTML">
Evidence
The PR touched the export link markup and kept target="_blank" but did not add `rel="noopener
noreferrer". The codebase already uses rel="noopener" on other target="_blank"` links,
indicating this protection is expected.

src/templates/pad.html[218-235]
src/node/handler/ExportHandler.ts[58-87]
src/templates/pad.html[183-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`target="_blank"` links without `rel="noopener noreferrer"` expose `window.opener` to the new tab.
### Issue Context
The export links in the import/export dialog open in a new tab and were modified in this PR. Other links in the same template already include `rel="noopener"`, so adding it here is consistent.
### Fix Focus Areas
- src/templates/pad.html[218-235]
### Suggested fix
Add `rel="noopener noreferrer"` to each export `<a ... target="_blank" ...>` element (all six). If you need referrer behavior, keep it consistent with existing link policies.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


21. Escape won’t close userlist🐞 Bug ≡ Correctness
Description
The new Escape path calls toggleDropDown('none'), but toggleDropDown('none') intentionally skips the
'users' dropdown and #users is not a .toolbar-popup element. Escape pressed while focus is inside
the Users popup therefore won’t close it or restore focus to the trigger.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
Escape handling always routes to toggleDropDown('none') when focus is inside an open popup, but
toggleDropDown('none') skips closing the users dropdown. Because the Users popup is rendered as
class="popup" (no toolbar-popup class), the earlier $('.toolbar-popup').removeClass('popup-show')
line does not affect it, leaving it open on Escape.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[219-246]
src/templates/pad.html[348-376]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Escape-to-close calls `toggleDropDown('none')`, but that code path skips closi...

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Accessibility: dialog semantics, focus management, icon labels, and html lang

🐞 Bug fix ✨ Enhancement 🧪 Tests

Grey Divider

Walkthroughs

Description
• Add lang and dir attributes to HTML templates negotiated from Accept-Language header
• Implement dialog semantics (role, aria-modal, aria-labelledby/aria-label) on all popups
• Add focus management: remember trigger element, focus first focusable on open, restore on close
• Enable Escape key to close popups while preserving focus restoration
• Convert icon-only controls to semantic buttons with accessible labels (chat icon, close/pin
  buttons, show-more, export links)
• Fix invalid aria-role="document" typo on userlist; replace with role="region" and
  aria-live="polite"
• Add comprehensive Playwright test suite covering dialog ARIA, lang attribute, focus behavior, and
  button semantics
Diagram
flowchart LR
  A["Templates<br/>pad.html, index.html,<br/>timeslider.html"] -->|"Add lang/dir<br/>from Accept-Language"| B["HTML Element<br/>lang & dir attrs"]
  C["Popup Elements<br/>#settings, #users,<br/>#embed, etc."] -->|"Add ARIA<br/>semantics"| D["Dialog Roles<br/>aria-modal, aria-labelledby"]
  E["toggleDropDown<br/>in pad_editbar.ts"] -->|"Remember trigger &<br/>focus management"| F["Focus Restoration<br/>on popup close"]
  G["Escape Key<br/>Handler"] -->|"Close popup<br/>& restore focus"| F
  H["Icon-only Controls<br/>chat, export, show-more"] -->|"Convert to buttons<br/>with aria-label"| I["Semantic Buttons<br/>with accessible names"]
  J["Tests<br/>a11y_dialogs.spec.ts"] -->|"Verify all<br/>changes"| K["Dialog & Focus<br/>Coverage"]
Loading

Grey Divider

File Changes

1. src/templates/pad.html ✨ Enhancement +36/-33

Add dialog ARIA semantics, focus management, and button labels

• Add lang and dir attributes to <html> element negotiated from Accept-Language header
• Add role="dialog", aria-modal="true", and aria-labelledby/aria-label to all popup divs
 (#settings, #import_export, #embed, #users, #mycolorpicker, #skin-variants, #connectivity)
• Add unique IDs to popup heading elements for aria-labelledby references
• Convert #chaticon from div with onclick to semantic <button> with aria-label="Open chat"
• Convert #titlecross and #titlesticky from anchors to buttons with descriptive aria-label
 attributes
• Convert .show-more-icon-btn from span to button with aria-label and aria-expanded attribute
• Add aria-label and aria-hidden="true" to all six export links and their inner icon spans
• Fix #otherusers from invalid aria-role="document" to role="region" with aria-live="polite"
 and aria-label
• Update theme switcher knob label from CSS-style identifier to human-readable text
• Add event handlers in chat.ts for button click events

src/templates/pad.html


2. src/static/js/pad_editbar.ts ✨ Enhancement +37/-1

Implement focus management and Escape-to-close for popups

• Add _lastTrigger field to remember the element that opened a popup for focus restoration
• Enhance toggleDropDown() to capture active element before opening popup
• Implement focus movement to first focusable element inside popup on open using
 requestAnimationFrame
• Restore focus to trigger element when all popups close
• Add Escape key handler in _bodyKeyEvent() to close open popups and trigger focus restoration
• Update .show-more-icon-btn click handler to toggle and sync aria-expanded attribute

src/static/js/pad_editbar.ts


3. src/static/js/chat.ts ✨ Enhancement +13/-0

Wire up chat button click handlers

• Add click event handlers for #chaticon, #titlecross, and #titlesticky to replace inline
 onclick attributes
• Move chat show/hide/stickToScreen logic from HTML onclick to jQuery event listeners

src/static/js/chat.ts


View more (5)
4. src/static/css/pad/chat.css ✨ Enhancement +16/-0

Style chat buttons and focus indicators

• Add CSS reset for button styling on #chaticon, #titlecross, #titlesticky (transparent
 background, no border, inherit font/color)
• Add :focus-visible outline styling for keyboard navigation on chat header buttons
• Add :focus-visible outline styling for #chaticon button

src/static/css/pad/chat.css


5. src/templates/index.html ✨ Enhancement +7/-1

Add lang/dir negotiation to index template

• Add server-side language negotiation logic using req.acceptsLanguages() against available
 languages
• Add lang and dir attributes to <html> element based on negotiated language

src/templates/index.html


6. src/templates/timeslider.html ✨ Enhancement +4/-1

Add lang/dir negotiation to timeslider template

• Add server-side language negotiation logic using req.acceptsLanguages() against available
 languages
• Add lang and dir attributes to <html> element based on negotiated language

src/templates/timeslider.html


7. src/tests/frontend-new/specs/a11y_dialogs.spec.ts 🧪 Tests +110/-0

Add comprehensive a11y dialog and focus tests

• Create new Playwright test suite for accessibility features
• Test <html> element has non-empty lang attribute
• Test settings popup has dialog semantics, Escape closes it, and focus returns to trigger
• Test import_export, embed, and users popups have proper dialog ARIA attributes
• Test all six export links have descriptive aria-label attributes
• Test chaticon is a button with accessible name
• Test chat header close/pin controls are buttons with labels
• Test otherusers region has role="region", aria-live="polite", and no invalid aria-role
 attribute
• Test show-more toolbar button has aria-label and aria-expanded attributes

src/tests/frontend-new/specs/a11y_dialogs.spec.ts


8. docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md 📝 Documentation +404/-0

Add detailed accessibility implementation plan documentation

• Document comprehensive plan for accessibility improvements addressing dialog semantics, focus
 management, icon labels, and html lang
• Provide step-by-step implementation guide with 7 major tasks and 40+ subtasks
• Include code examples, test cases, and out-of-scope items
• Serve as reference documentation for the PR changes

docs/superpowers/plans/2026-04-22-a11y-dialogs-labels-lang.md


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 23, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Remediation recommended

1. Escape won’t close userlist 🐞 Bug ≡ Correctness
Description
The new Escape path calls toggleDropDown('none'), but toggleDropDown('none') intentionally skips the
'users' dropdown and #users is not a .toolbar-popup element. Escape pressed while focus is inside
the Users popup therefore won’t close it or restore focus to the trigger.
Code

src/static/js/pad_editbar.ts[R330-336]

+    // Escape from inside any open popup: close the popup and let
+    // toggleDropDown('none') restore focus to the trigger.
+    if (evt.keyCode === 27 && $(':focus').closest('.popup.popup-show').length === 1) {
+      this.toggleDropDown('none');
+      evt.preventDefault();
+      return;
+    }
Evidence
Escape handling always routes to toggleDropDown('none') when focus is inside an open popup, but
toggleDropDown('none') skips closing the users dropdown. Because the Users popup is rendered as
class="popup" (no toolbar-popup class), the earlier $('.toolbar-popup').removeClass('popup-show')
line does not affect it, leaving it open on Escape.

src/static/js/pad_editbar.ts[329-336]
src/static/js/pad_editbar.ts[219-246]
src/templates/pad.html[348-376]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Escape-to-close calls `toggleDropDown('none')`, but that code path skips closing the `users` dropdown, so Escape won’t dismiss the Users popup.

### Issue Context
- `toggleDropDown('none')` currently has a hard-coded `if (thisModuleName === 'users') continue;`.
- `#users` in the template has `class="popup"` (no `toolbar-popup`), so `$('.toolbar-popup').removeClass('popup-show')` does not close it.

### Fix Focus Areas
- src/static/js/pad_editbar.ts[211-246]
- src/static/js/pad_editbar.ts[329-336]
- src/templates/pad.html[348-376]

### Suggested fix
- In the `moduleName === 'none'` branch, close `users` unless it is sticky:
 - Replace the unconditional skip with something like:
   - `if (thisModuleName === 'users' && $('#users').hasClass('stickyUsers')) continue;`
   - otherwise remove `popup-show` from `#users` the same way as other dropdowns.
- Alternatively, in the Escape handler, detect `#users.popup-show` and explicitly close it (again respecting `stickyUsers`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Show-more button default styling 🐞 Bug ⚙ Maintainability
Description
The show-more control changed from a span to a button, but there is no CSS reset for native button
border/background/padding. When it becomes visible in cropped-toolbar mode, it can render with
browser-default button chrome and spacing.
Code

src/templates/pad.html[77]

+      <button type="button" class="show-more-icon-btn" aria-label="Show more toolbar buttons" aria-expanded="false"></button> <!-- use on small screen to display hidden toolbar buttons -->
Evidence
The template now emits a <button> for .show-more-icon-btn. The existing CSS sets
sizing/typography but does not reset default button styles (border/background/padding/appearance),
which were irrelevant when the element was a <span>.

src/templates/pad.html[64-78]
src/static/css/pad/toolbar.css[100-110]
src/static/skins/colibris/src/components/toolbar.css[116-124]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`.show-more-icon-btn` is now a `<button>` and may pick up browser default border/background/padding unless explicitly reset.

### Issue Context
The existing toolbar CSS styles `.show-more-icon-btn` (size, line-height, etc.) but does not neutralize native button UI. Previously this was a `<span>` so it had no native chrome.

### Fix Focus Areas
- src/templates/pad.html[64-78]
- src/static/css/pad/toolbar.css[100-110]
- src/static/skins/colibris/src/components/toolbar.css[116-124]

### Suggested fix
Add a reset for `.toolbar .show-more-icon-btn` (in both the default toolbar CSS and colibris skin if needed), for example:
```css
.toolbar .show-more-icon-btn {
 background: transparent;
 border: 0;
 padding: 0;
 font: inherit;
 color: inherit;
 appearance: none;
}
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Blank-target export lacks noopener 🐞 Bug ⛨ Security
Description
The export links still use target="_blank" without rel="noopener noreferrer", allowing the opened
page to access window.opener. This enables tabnabbing/opener navigation if the opened document
executes script.
Code

src/templates/pad.html[R218-221]

+              <a id="exportetherpada" target="_blank" class="exportlink" aria-label="Export as Etherpad">
+                <span class="exporttype buttonicon buttonicon-file-powerpoint" id="exportetherpad" data-l10n-id="pad.importExport.exportetherpad" aria-hidden="true"></span>
              </a>
-              <a id="exporthtmla" target="_blank" class="exportlink">
-                <span class="exporttype buttonicon buttonicon-file-code" id="exporthtml" data-l10n-id="pad.importExport.exporthtml"></span>
+              <a id="exporthtmla" target="_blank" class="exportlink" aria-label="Export as HTML">
Evidence
The PR touched the export link markup and kept target="_blank" but did not add `rel="noopener
noreferrer". The codebase already uses rel="noopener" on other target="_blank"` links,
indicating this protection is expected.

src/templates/pad.html[218-235]
src/node/handler/ExportHandler.ts[58-87]
src/templates/pad.html[183-186]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`target="_blank"` links without `rel="noopener noreferrer"` expose `window.opener` to the new tab.

### Issue Context
The export links in the import/export dialog open in a new tab and were modified in this PR. Other links in the same template already include `rel="noopener"`, so adding it here is consistent.

### Fix Focus Areas
- src/templates/pad.html[218-235]

### Suggested fix
Add `rel="noopener noreferrer"` to each export `<a ... target="_blank" ...>` element (all six). If you need referrer behavior, keep it consistent with existing link policies.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/static/js/pad_editbar.ts
Comment thread src/static/js/pad_editbar.ts Outdated
JohnMcLear and others added 8 commits April 23, 2026 08:57
)

* fix(editor): preserve U+00A0 non-breaking space (ether#3037)

Non-breaking spaces were silently normalized to regular spaces at every
ingestion point, so typed/pasted/imported nbsps never reached the
changeset and users could not glue words against line-wrap in French or
other languages that require nbsp typography.

Removed the four strip sites that replaced U+00A0 with U+0020:
  - src/node/db/Pad.ts cleanText
  - src/static/js/contentcollector.ts textify
  - src/static/js/ace2_inner.ts textify
  - src/static/js/ace2_inner.ts importText raw-text guard

Updated both processSpaces functions (domline and ExportHtml) to tokenize
U+00A0 as a separate unit, emit it verbatim as &nbsp;, and treat it as
content (not whitespace) for the run-collapse bookkeeping so adjacent
regular-space runs aren't miscounted.

Added backend round-trip tests for spliceText and setText, and extended
the cleanText case table. Updated the existing contentcollector and
importexport specs whose expectations encoded the previous buggy
behavior; they now assert genuine nbsp preservation.

Verified manually in Firefox: clipboard U+00A0 → paste → pad → getText
returns c2 a0; getHTML emits `100&nbsp;km`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(contentcollector): collapse display-artifact nbsp runs on DOM read-back

processSpaces is a lossy one-way display transform: leading/trailing
spaces and all-but-the-last of a run get rendered as &nbsp; so HTML
doesn't collapse them. When incorporateUserChanges reads text back from
the DOM, those display-artifact nbsps were being stored in the changeset
model instead of being normalized back to plain spaces.

This broke handleReturnIndentation, whose /^ *(?:)/ regex only matches
ASCII spaces: auto-indent after `foo:\n` produced 4 spaces instead of
the expected prev-indent (2) + THE_TAB (4) = 6, because the previous
line's model had nbsps where it used to have spaces.

Fix: in contentcollector.textify, collapse any [  ]+ run back to
plain spaces UNLESS the run is pure U+00A0 AND strictly interior to
word chars. That preserves user-intended typographic nbsps like
"100 km" while undoing the one-way display transform.

Updated 7 contentcollector tests and 7 importexport tests whose
assertions needed to reflect the new rule (boundary/mixed runs collapse;
pure-interior nbsp runs preserve).

Fixes the Playwright regression in indentation.spec.ts:117 that the
previous commit introduced.

* fix(contentcollector): canonicalize nbsp runs at line assembly, not per text node

Addresses Qodo code review feedback on PR ether#7585.

## Bug fix — nbsp lost at DOM text-node boundary

The previous approach ran the "collapse display-artifact nbsp" rule inside
textify(), which is called per individual DOM TEXT_NODE. A user-intended
nbsp sitting at a text-node boundary (e.g., <span>100</span><span>&nbsp;km
</span>) was incorrectly seen as non-interior (before === '' for the second
text node) and normalized back to a regular space.

Fix: move the canonicalization out of textify() and run it on each
fully assembled line string inside cc.finish(). The rule remains:

    [  ]+ run  ->  plain spaces
                   UNLESS pure U+00A0 AND strictly interior to non-ws chars

It is length-preserving, so attribute offsets and line lengths are
unaffected.

Added a regression test (contentcollector.spec.ts) for the cross-span
case.

## Docs concern

Reverted the type-only addition of spliceText to PadType. spliceText
is an existing Pad runtime method; the backend test now uses a cast
(`(pad as any).spliceText`) so the PR does not expand the declared
public type surface, avoiding a separate documentation requirement.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tization (ether#7587)

PadMessageHandler built the `pluginsSanitized` payload for clientVars by
aliasing `plugins.plugins` and then mutating each entry's `package` field
in place:

    let pluginsSanitized: any = plugins.plugins;
    Object.keys(plugins.plugins).forEach(function(element) {
      const p: any = plugins.plugins[element].package;
      pluginsSanitized[element].package = {name: p.name, version: p.version};
    });

Because `pluginsSanitized` is a reference to `plugins.plugins`, the
assignment clobbered the server-side plugin registry. After the first
pad connection, every plugin's `package` object held only `{name,
version}` — `realPath`, `path`, and `location` were gone.

Minify.ts resolves `/static/plugins/ep_*/...` URLs via
`plugin.package.realPath`. Once the field disappeared, every subsequent
static asset request for a bundled plugin 500'd with:

    TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of
    type string. Received undefined
        at Object.join (node:path:1354:7)
        at _minify (src/node/utils/Minify.ts:181:23)

Symptoms on Chromium: plugin CSS/JS assets fail to load (e.g.
/static/plugins/ep_font_size/static/css/size.css returns 500), so
plugins partially render or don't work at all. Firefox swallows the
resulting console errors quietly.

Fix: extract the sanitization into a pure helper `sanitizePluginsForWire`
that returns a fresh object graph and never touches the input. The
helper is covered by a new backend spec that:
  * verifies the sanitized output has only {name, version} in `package`
  * asserts the input registry's realPath/path/location survive the call
  * runs the call repeatedly and confirms non-destructiveness
  * mutates the returned copy and asserts the input is independent

Verified live with the dev server: before the fix, `/static/plugins/
ep_font_size/static/css/size.css` 500'd after visiting any pad; after
the fix it returns 200 both before and after pad connections.
Server-renders the html element with `lang` and `dir` matching the
client's Accept-Language header (negotiated against availableLangs from
i18n hooks). Falls back to `en`/`ltr` if no match.

This gives screen readers a correct document language during the brief
window before client-side html10n refines it (l10n.ts already sets both
attributes after locale data loads).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds role=dialog, aria-modal=true, and either aria-labelledby (when an
h1 is present) or aria-label (for popups without an h1) to:

  - #settings, #import_export, #embed, #skin-variants (labelledby)
  - #connectivity, #users, #mycolorpicker (aria-label)

Fixes the invalid aria-role="document" attribute on #otherusers; it's
now role=region with aria-live=polite so screen readers announce
collaborator joins/leaves.

Container aria-label values are English-only for now — Etherpad's
html10n implementation only supports localizing specific attributes
(title, alt, placeholder, etc), not aria-label on container nodes.
Localization can follow once html10n grows that affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three additions to toggleDropDown / _bodyKeyEvent:

  - Remember the trigger element (document.activeElement) when opening
    a popup, so we can restore focus when it closes.
  - On open, focus the first focusable element inside the popup so
    keyboard users land inside the dialog instead of staying on the
    trigger button.
  - Escape pressed while focus is inside a popup closes it, then the
    restore-focus path runs and the trigger button is refocused.

Replaces the previous behavior where Escape from inside a popup did
nothing; users had to click outside to dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- #chaticon: <div onclick> → <button type=button> with aria-label
- #titlecross / #titlesticky: <a onClick> → <button type=button>
  with aria-label (Close chat / Pin chat to screen)
- Decorative chat-bubble glyph gets aria-hidden=true so it isn't
  read alongside the button label
- #chatcounter labelled "Unread messages"
- Inline onclick attributes moved to chat.init() handlers
- CSS reset on the new buttons (transparent bg, no border, inherit
  font/color) so they match the prior visual design
- :focus-visible outlines for keyboard users

Existing test selectors (#chaticon, #titlecross, #titlesticky) are
unchanged and continue to work — they never relied on element type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Export links (#exportetherpada, #exporthtmla, #exportplaina,
  #exportworda, #exportpdfa, #exportopena): added aria-label so the
  link is announced as e.g. "Export as PDF". The inner icon span
  gets aria-hidden=true so screen readers don't read both the icon
  text and the link label.

- Show-more toolbar toggle (.show-more-icon-btn): converted from
  <span> to <button type=button> with aria-label and aria-expanded.
  The click handler now toggles aria-expanded alongside the
  full-icons class so assistive tech reflects the open/closed state.

- Theme switcher knob: aria-label changed from "theme-switcher-knob"
  (a class-style identifier, not human text) to "Toggle theme".

Aria-label values are English-only for now. Etherpad's html10n
implementation only localizes a fixed attribute list (title, alt,
placeholder, value, innerHTML, textContent); aria-label is not
included, so a clean l10n path requires a follow-up to either
extend html10n or set aria-label client-side after locale loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New Playwright spec verifies the a11y guarantees added by this branch:

  - <html> has a non-empty lang attribute
  - settings/import_export/embed/users popups expose role=dialog,
    aria-modal=true, and either aria-labelledby (when an h1 exists)
    or aria-label (when none does)
  - Escape from inside the settings popup closes it AND restores
    focus to the trigger button
  - Export links each carry a descriptive aria-label
  - #chaticon is a real <button> with aria-label
  - #titlecross / #titlesticky are real <button>s with aria-label
  - #otherusers uses role=region + aria-live=polite + aria-label
    (and the previous aria-role typo is gone)
  - .show-more-icon-btn is a <button> with aria-label and
    aria-expanded

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JohnMcLear JohnMcLear force-pushed the fix/a11y-dialogs-labels-lang branch from 818dceb to 68a0761 Compare April 23, 2026 10:00
1. Users Escape close broken - toggleDropDown('none') intentionally
   skips the users module so switching between other popups doesn't
   hide the user list. That meant Escape couldn't dismiss the Users
   popup either. The Escape branch now checks for #users as the
   focused popup and closes it explicitly (respecting stickyUsers)
   before falling through to the normal close-all path.

2. Embed focus overridden - the rAF auto-focus in toggleDropDown
   grabbed the first focusable descendant, which stole focus from
   command handlers that target a specific control (notably the Embed
   command's #linkinput). rAF now bails out if focus is already
   inside the newly-opened popup.

3. Button click blurs :focus before toggleDropDown captures trigger -
   discovered while investigating the Firefox Playwright failure for
   "settings popup Escape restores focus". Button.bind() calls
   $(':focus').trigger('blur') before invoking the callback, so by
   the time toggleDropDown() captured document.activeElement as the
   restore target it was already <body>. The click handler now
   stashes padeditbar._lastTrigger to the clicked <button> before
   blur runs; toggleDropDown only falls back to activeElement when
   the pre-stash didn't happen (keyboard shortcut path).

4. html10n overwrites aria-label - html10n unconditionally set
   aria-label to the translated string, clobbering explicit aria-label
   on elements that also carry data-l10n-id. setAttribute now only
   fires when the element has no aria-label; explicit author labels
   win, unlabelled translated elements still get a name.

5. Button visual reset - the show-more-icon-btn and #chaticon
   conversions inherited UA default button border/background/padding,
   shifting icon glyphs visibly off-centre. Added appearance /
   background / border / padding resets.

6. Export links test assumes soffice is installed - #exportworda,
   #exportpdfa, #exportopena are removed client-side by pad_impexp.ts
   when clientVars.exportAvailable === 'no'. The test now skips links
   absent at runtime.

Verified locally: all 10 a11y_dialogs specs pass on both Chromium and
Firefox; backend suite remains 799/799 passing; ts-check clean.
…layout

Round 2 of ether#7584 review follow-ups.

1. Users popup Escape still didn't close the dialog (user-confirmed).
   Root cause: _bodyKeyEvent is bound to the OUTER document's body.
   When #users opens, the command handler tries to focus
   #myusernameedit but that input is `disabled`, so focus stays in the
   ace editor iframe. Keydown from inside the iframe does not bubble
   to the outer document, so Esc never reaches _bodyKeyEvent.
   Fix: in the open-popup rAF, if no command handler placed focus
   inside the dialog, focus the popup div itself (with tabindex=-1).
   That keeps subsequent keydown events on the outer document so
   Esc can dismiss the popup. Also broadened the Esc branch to fire
   whenever any popup is `.popup-show`, regardless of where :focus
   lives — some popups legitimately have no focusable content at
   open.
   Added a regression test that opens #users and asserts Esc closes
   it. Passes on both Chromium and Firefox.

2. Chat icon (#chaticon) visual still wrong after the first CSS fix.
   - My previous `border: 0` reset was overriding the intended
     `border: 1px solid #ccc; border-bottom: none` from the earlier
     rule. Removed `border: 0`; the earlier explicit border suffices
     to suppress UA defaults.
   - The `<span class="buttonicon">` inside `#chaticon` was picking
     up the global `.buttonicon { display: flex; }` rule meant for
     toolbar button instances, which broke the inline layout of the
     label + glyph + counter row. Added a scoped
     `#chaticon .buttonicon { display: inline; }` override.

All 11 a11y_dialogs specs pass on Chromium and Firefox. Backend
suite and ts-check remain clean.
Round 3 follow-up. The previous Button.bind() change stashed every
clicked toolbar button as padeditbar._lastTrigger before blurring :focus.
That was necessary for popup-opening buttons (settings, import_export,
etc.) so Escape could return focus to them — but it also fired for
non-popup toolbar buttons (list toggles, bold/italic, indent/outdent,
clearauthorship). For those, the stash held a stale reference that
interfered with subsequent editor interactions and regressed Playwright
tests: ordered_list, unordered_list, undo_clear_authorship.

Fix: only stash when the clicked command is a registered dropdown
(settings, import_export, embed, showusers, savedrevision,
connectivity). Other commands return focus to the ace editor as before
and leave _lastTrigger alone.

Verified locally on Chromium:
  - ordered_list.spec.ts: 6/6 pass (was 4/6)
  - unordered_list.spec.ts: 6/6 pass (was 4/6)
  - undo_clear_authorship.spec.ts: 2/2 pass (was 0/2)
  - a11y_dialogs.spec.ts: 11/11 pass (unchanged)
#1 Stale aria-label after relocalize
  html10n.translateNode() refused to overwrite any existing aria-label,
  which also skipped updates on language change (pad.applyLanguage()
  re-runs localize). Use a `data-l10n-aria-label="true"` marker: set
  aria-label + marker when html10n populates it, overwrite only if the
  marker is present. Explicit template-supplied aria-labels stay as-is;
  html10n-generated ones refresh on relocalize.

#2 Escape won't close colorpicker
  _bodyKeyEvent caught Escape on any `.popup.popup-show` but only
  closed dropdown popups via toggleDropDown('none'). Popups opened
  outside the editbar framework (#mycolorpicker, toggled directly by
  pad_userlist.ts) stayed open while preventDefault() swallowed the
  key. Now the Escape branch manually closes any popup that
  toggleDropDown('none') cannot reach (non-dropdown ids, plus #users
  unless pinned) and leaves registered dropdowns for toggleDropDown to
  close so its focus-restore sees the transition.

ether#3 Stale focus restoration
  toggleDropDown('none') restored focus to _lastTrigger even when no
  popup was open on entry, which meant background callers
  (connectivity setup, periodic state handling) could yank focus out
  of the editor to a stale toolbar button. Gated the restore on
  `wasAnyOpen === true` so it only fires when there was a popup to
  close.

ether#11 English aria-label overrides i18n (export links, chat icon)
  Removed the hard-coded English aria-label from export anchors and
  removed aria-hidden from their inner localized spans. Screen readers
  now get the localized child text as the accessible name (Etherpad,
  HTML, PDF, etc.), matching the visible UI language.
  Removed the English aria-label from #chaticon and #titlesticky as
  well — both have data-l10n-id, so html10n populates a localized
  aria-label via the marker mechanism in #1. #titlecross keeps its
  static aria-label because it has no data-l10n-id yet.

ether#4 4-space indent in a11y spec
  Two tests had continuation lines at 4-space indent violating the
  repo's 2-space rule. Folded the signatures onto one line.

Updated a11y_dialogs.spec.ts to assert accessible-name presence rather
than hard-coded English for elements whose names now come from the
localized text. Still asserts static English for #titlecross (not
localized yet).

Verified locally (dev server restarted for each round):
  - a11y_dialogs.spec.ts: 11/11 on Chromium, 11/11 on Firefox
  - ordered_list + unordered_list + undo_clear_authorship: 13/13 on Chromium
  - Full backend suite: 799 passing, 0 failing
  - tsc --noEmit clean in our code

ether#9 Popup behavior documentation: deferred to a follow-up doc PR so
this PR stays focused on the a11y code changes. The new keyboard
behavior (Escape-to-close, focus-restore-to-trigger) is small enough
to summarize in a short doc/ addition.
JohnMcLear added a commit that referenced this pull request Apr 24, 2026
…ether#7584)

* fix(a11y): negotiate lang/dir per request and set on <html>

Server-renders the html element with `lang` and `dir` matching the
client's Accept-Language header (negotiated against availableLangs from
i18n hooks). Falls back to `en`/`ltr` if no match.

This gives screen readers a correct document language during the brief
window before client-side html10n refines it (l10n.ts already sets both
attributes after locale data loads).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): dialog semantics on popups; fix aria-role typo on userlist

Adds role=dialog, aria-modal=true, and either aria-labelledby (when an
h1 is present) or aria-label (for popups without an h1) to:

  - #settings, #import_export, #embed, #skin-variants (labelledby)
  - #connectivity, #users, #mycolorpicker (aria-label)

Fixes the invalid aria-role="document" attribute on #otherusers; it's
now role=region with aria-live=polite so screen readers announce
collaborator joins/leaves.

Container aria-label values are English-only for now — Etherpad's
html10n implementation only supports localizing specific attributes
(title, alt, placeholder, etc), not aria-label on container nodes.
Localization can follow once html10n grows that affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): focus management and Escape-to-close for popups

Three additions to toggleDropDown / _bodyKeyEvent:

  - Remember the trigger element (document.activeElement) when opening
    a popup, so we can restore focus when it closes.
  - On open, focus the first focusable element inside the popup so
    keyboard users land inside the dialog instead of staying on the
    trigger button.
  - Escape pressed while focus is inside a popup closes it, then the
    restore-focus path runs and the trigger button is refocused.

Replaces the previous behavior where Escape from inside a popup did
nothing; users had to click outside to dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): make chaticon and chat header controls real buttons

- #chaticon: <div onclick> → <button type=button> with aria-label
- #titlecross / #titlesticky: <a onClick> → <button type=button>
  with aria-label (Close chat / Pin chat to screen)
- Decorative chat-bubble glyph gets aria-hidden=true so it isn't
  read alongside the button label
- #chatcounter labelled "Unread messages"
- Inline onclick attributes moved to chat.init() handlers
- CSS reset on the new buttons (transparent bg, no border, inherit
  font/color) so they match the prior visual design
- :focus-visible outlines for keyboard users

Existing test selectors (#chaticon, #titlecross, #titlesticky) are
unchanged and continue to work — they never relied on element type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): accessible names for icon-only toolbar/export controls

- Export links (#exportetherpada, #exporthtmla, #exportplaina,
  #exportworda, #exportpdfa, #exportopena): added aria-label so the
  link is announced as e.g. "Export as PDF". The inner icon span
  gets aria-hidden=true so screen readers don't read both the icon
  text and the link label.

- Show-more toolbar toggle (.show-more-icon-btn): converted from
  <span> to <button type=button> with aria-label and aria-expanded.
  The click handler now toggles aria-expanded alongside the
  full-icons class so assistive tech reflects the open/closed state.

- Theme switcher knob: aria-label changed from "theme-switcher-knob"
  (a class-style identifier, not human text) to "Toggle theme".

Aria-label values are English-only for now. Etherpad's html10n
implementation only localizes a fixed attribute list (title, alt,
placeholder, value, innerHTML, textContent); aria-label is not
included, so a clean l10n path requires a follow-up to either
extend html10n or set aria-label client-side after locale loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(a11y): cover dialog semantics, html lang, icon button labels

New Playwright spec verifies the a11y guarantees added by this branch:

  - <html> has a non-empty lang attribute
  - settings/import_export/embed/users popups expose role=dialog,
    aria-modal=true, and either aria-labelledby (when an h1 exists)
    or aria-label (when none does)
  - Escape from inside the settings popup closes it AND restores
    focus to the trigger button
  - Export links each carry a descriptive aria-label
  - #chaticon is a real <button> with aria-label
  - #titlecross / #titlesticky are real <button>s with aria-label
  - #otherusers uses role=region + aria-live=polite + aria-label
    (and the previous aria-role typo is gone)
  - .show-more-icon-btn is a <button> with aria-label and
    aria-expanded

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): address Qodo review feedback from PR ether#7584

1. Users Escape close broken - toggleDropDown('none') intentionally
   skips the users module so switching between other popups doesn't
   hide the user list. That meant Escape couldn't dismiss the Users
   popup either. The Escape branch now checks for #users as the
   focused popup and closes it explicitly (respecting stickyUsers)
   before falling through to the normal close-all path.

2. Embed focus overridden - the rAF auto-focus in toggleDropDown
   grabbed the first focusable descendant, which stole focus from
   command handlers that target a specific control (notably the Embed
   command's #linkinput). rAF now bails out if focus is already
   inside the newly-opened popup.

3. Button click blurs :focus before toggleDropDown captures trigger -
   discovered while investigating the Firefox Playwright failure for
   "settings popup Escape restores focus". Button.bind() calls
   $(':focus').trigger('blur') before invoking the callback, so by
   the time toggleDropDown() captured document.activeElement as the
   restore target it was already <body>. The click handler now
   stashes padeditbar._lastTrigger to the clicked <button> before
   blur runs; toggleDropDown only falls back to activeElement when
   the pre-stash didn't happen (keyboard shortcut path).

4. html10n overwrites aria-label - html10n unconditionally set
   aria-label to the translated string, clobbering explicit aria-label
   on elements that also carry data-l10n-id. setAttribute now only
   fires when the element has no aria-label; explicit author labels
   win, unlabelled translated elements still get a name.

5. Button visual reset - the show-more-icon-btn and #chaticon
   conversions inherited UA default button border/background/padding,
   shifting icon glyphs visibly off-centre. Added appearance /
   background / border / padding resets.

6. Export links test assumes soffice is installed - #exportworda,
   #exportpdfa, #exportopena are removed client-side by pad_impexp.ts
   when clientVars.exportAvailable === 'no'. The test now skips links
   absent at runtime.

Verified locally: all 10 a11y_dialogs specs pass on both Chromium and
Firefox; backend suite remains 799/799 passing; ts-check clean.

* fix(a11y): close popups with no focusable content; unbreak chat-icon layout

Round 2 of ether#7584 review follow-ups.

1. Users popup Escape still didn't close the dialog (user-confirmed).
   Root cause: _bodyKeyEvent is bound to the OUTER document's body.
   When #users opens, the command handler tries to focus
   #myusernameedit but that input is `disabled`, so focus stays in the
   ace editor iframe. Keydown from inside the iframe does not bubble
   to the outer document, so Esc never reaches _bodyKeyEvent.
   Fix: in the open-popup rAF, if no command handler placed focus
   inside the dialog, focus the popup div itself (with tabindex=-1).
   That keeps subsequent keydown events on the outer document so
   Esc can dismiss the popup. Also broadened the Esc branch to fire
   whenever any popup is `.popup-show`, regardless of where :focus
   lives — some popups legitimately have no focusable content at
   open.
   Added a regression test that opens #users and asserts Esc closes
   it. Passes on both Chromium and Firefox.

2. Chat icon (#chaticon) visual still wrong after the first CSS fix.
   - My previous `border: 0` reset was overriding the intended
     `border: 1px solid #ccc; border-bottom: none` from the earlier
     rule. Removed `border: 0`; the earlier explicit border suffices
     to suppress UA defaults.
   - The `<span class="buttonicon">` inside `#chaticon` was picking
     up the global `.buttonicon { display: flex; }` rule meant for
     toolbar button instances, which broke the inline layout of the
     label + glyph + counter row. Added a scoped
     `#chaticon .buttonicon { display: inline; }` override.

All 11 a11y_dialogs specs pass on Chromium and Firefox. Backend
suite and ts-check remain clean.

* fix(a11y): only stash _lastTrigger for dropdown-opening buttons

Round 3 follow-up. The previous Button.bind() change stashed every
clicked toolbar button as padeditbar._lastTrigger before blurring :focus.
That was necessary for popup-opening buttons (settings, import_export,
etc.) so Escape could return focus to them — but it also fired for
non-popup toolbar buttons (list toggles, bold/italic, indent/outdent,
clearauthorship). For those, the stash held a stale reference that
interfered with subsequent editor interactions and regressed Playwright
tests: ordered_list, unordered_list, undo_clear_authorship.

Fix: only stash when the clicked command is a registered dropdown
(settings, import_export, embed, showusers, savedrevision,
connectivity). Other commands return focus to the ace editor as before
and leave _lastTrigger alone.

Verified locally on Chromium:
  - ordered_list.spec.ts: 6/6 pass (was 4/6)
  - unordered_list.spec.ts: 6/6 pass (was 4/6)
  - undo_clear_authorship.spec.ts: 2/2 pass (was 0/2)
  - a11y_dialogs.spec.ts: 11/11 pass (unchanged)

* fix(a11y): address Qodo review round 4 for PR ether#7584

#1 Stale aria-label after relocalize
  html10n.translateNode() refused to overwrite any existing aria-label,
  which also skipped updates on language change (pad.applyLanguage()
  re-runs localize). Use a `data-l10n-aria-label="true"` marker: set
  aria-label + marker when html10n populates it, overwrite only if the
  marker is present. Explicit template-supplied aria-labels stay as-is;
  html10n-generated ones refresh on relocalize.

#2 Escape won't close colorpicker
  _bodyKeyEvent caught Escape on any `.popup.popup-show` but only
  closed dropdown popups via toggleDropDown('none'). Popups opened
  outside the editbar framework (#mycolorpicker, toggled directly by
  pad_userlist.ts) stayed open while preventDefault() swallowed the
  key. Now the Escape branch manually closes any popup that
  toggleDropDown('none') cannot reach (non-dropdown ids, plus #users
  unless pinned) and leaves registered dropdowns for toggleDropDown to
  close so its focus-restore sees the transition.

ether#3 Stale focus restoration
  toggleDropDown('none') restored focus to _lastTrigger even when no
  popup was open on entry, which meant background callers
  (connectivity setup, periodic state handling) could yank focus out
  of the editor to a stale toolbar button. Gated the restore on
  `wasAnyOpen === true` so it only fires when there was a popup to
  close.

ether#11 English aria-label overrides i18n (export links, chat icon)
  Removed the hard-coded English aria-label from export anchors and
  removed aria-hidden from their inner localized spans. Screen readers
  now get the localized child text as the accessible name (Etherpad,
  HTML, PDF, etc.), matching the visible UI language.
  Removed the English aria-label from #chaticon and #titlesticky as
  well — both have data-l10n-id, so html10n populates a localized
  aria-label via the marker mechanism in #1. #titlecross keeps its
  static aria-label because it has no data-l10n-id yet.

ether#4 4-space indent in a11y spec
  Two tests had continuation lines at 4-space indent violating the
  repo's 2-space rule. Folded the signatures onto one line.

Updated a11y_dialogs.spec.ts to assert accessible-name presence rather
than hard-coded English for elements whose names now come from the
localized text. Still asserts static English for #titlecross (not
localized yet).

Verified locally (dev server restarted for each round):
  - a11y_dialogs.spec.ts: 11/11 on Chromium, 11/11 on Firefox
  - ordered_list + unordered_list + undo_clear_authorship: 13/13 on Chromium
  - Full backend suite: 799 passing, 0 failing
  - tsc --noEmit clean in our code

ether#9 Popup behavior documentation: deferred to a follow-up doc PR so
this PR stays focused on the a11y code changes. The new keyboard
behavior (Escape-to-close, focus-restore-to-trigger) is small enough
to summarize in a short doc/ addition.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request Apr 29, 2026
Addresses Qodo review on ether#7544:

1. Requirement gap (#1): Add stability detection to focusOnLine()'s
   reapply loop. When the target line's offsetTop has not changed for
   3 consecutive 250ms ticks (~750ms), stop() is called early instead
   of running the full 10s window. This means once late content is no
   longer shifting layout, the loop releases the user immediately
   rather than waiting out maxSettleDuration.

2. Maintainability (ether#4): Add a comment explaining why the previous
   $.animate({scrollTop}) "needed for FF" path was replaced with a
   direct .scrollTop() call — the settle interval now covers the
   late-layout case Firefox originally needed animation for.

Also adds a test that the reapply loop exits early so a user-initiated
scrollTop=0 after ~2s is not reverted by another reapply tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request Apr 29, 2026
Round 2 of Qodo review on ether#7601. Addressing the action-required items:

#1 Badge bypassed pad baseURL — derive basePath the same way
   padBootstrap.js does (`new URL('..', window.location.href).pathname`)
   and prefix the fetch with it. Subpath deployments now reach
   /<prefix>/api/version-status instead of 404ing.

#2 Updater poller could get stuck — `getCurrentState()` is now inside
   the try/finally so a one-time loadState() rejection can't leave
   `checkInFlight=true` and permanently silence polling.

ether#3 Updates off hung admin page — UpdatePage now self-fetches and
   renders explicit `disabled` (404), `unauthorized` (401/403), and
   `error` states instead of staying on "Loading...". Banner-driven
   prefetch is still honoured if it landed first.

ether#11 NaN polling interval — coerce `checkIntervalHours` to a number,
   clamp to [1h, 168h], log a warning and fall back to 6h on
   non-finite input. Math.max(1, NaN) === NaN previously meant a
   malformed settings.json could turn the poller into a tight loop.

ether#13 State validation accepted broken subfields — `isValid()` now
   inspects `latest.{version,tag,body,publishedAt,htmlUrl,prerelease}`,
   `vulnerableBelow[].{announcedBy,threshold}`, and
   `email.{severeAt,vulnerableAt,vulnerableNewReleaseTag}`. A
   hand-edited file with a number where a string is expected is now
   treated as corrupt and reset to EMPTY_STATE rather than crashing
   later in semver parsing or email rendering.

ether#14 Badge cache stampede — wrap `computeOutdated()` in a single-flight
   promise so concurrent requests at cache expiry await one shared
   computation instead of fanning out into N redundant disk reads.

Plus six new state.test.ts cases covering each new validation guard.

Pushing back on the remaining items:

ether#4 `updates.tier` defaults to `notify` — intentional. The whole point
   of tier 1 is to surface the "you are behind" signal to admins by
   default. Opt-in defeats the purpose; the existing failure mode
   (admin never hears about a security-relevant release) is exactly
   what this PR is fixing.

ether#5/ether#8 Admin status endpoint admin-auth — `currentVersion` is already
   public via `/health`, so wrapping the route in admin-auth doesn't
   reduce the disclosure surface meaningfully. Operators who want it
   gated set `updates.requireAdminForStatus=true` (already wired and
   covered by the comment on the route handler).

ether#10 Plain `https://` URLs in planning doc — planning markdown is
   viewed in editors and on GitHub where protocol-relative URLs would
   either render literally or break entirely. Keeping `https://`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request Apr 30, 2026
…-out

Round 3 of Qodo review on ether#7544:

ether#3 Early exit misses late shifts — image loads / plugin renders past my
   previous 750ms early-exit window were no longer corrected. Add a
   `minSettleDuration` of 2s before any early-exit can fire, and bump
   `stableTicksRequired` from 3 to 4. Hard ceiling stays 10s.

ether#4 Offset equality prevents stability — strict === on `offset().top`
   never matched in the presence of sub-pixel rounding, so the loop
   ran the full 10s even on stable layouts. Switch to `Math.abs(...) <
   1` tolerance.

ether#7 Invalid anchors spin interval — when `getCurrentTargetOffset()`
   keeps returning null (the requested line never resolves), the loop
   used to run for the full 10s doing nothing. Track consecutive
   misses and `stop()` after `missingTicksRequired` (8 ticks ≈ 2s).
   Real "inner doc not yet rendered" cases get the first 2s window.

Bump the early-exit test's wait from 2s → 3.5s to clear the new
`minSettleDuration` + `stableTicksRequired` window before asserting.

Pushing back on remaining Qodo items:

#1 Defer scroll until layout settles — the design is "scroll once
   immediately so the user sees the line, then keep correcting".
   Deferring all scrolling until "stable" (which is unknowable up
   front) would visibly hang on `#L...` navigation for seconds while
   nothing happens. The reapply loop is the deferral.

ether#6 FF rationale lost — already addressed in the previous commit
   (comment on the `scrollTop()` call explaining why the
   `$.animate({scrollTop})` "needed for FF" path was removed). Qodo's
   persistent review doesn't track resolution of items that aren't
   touched by the new commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request Apr 30, 2026
Round 3 of Qodo review on ether#7561:

#2 Unknown update_type silently omitted — render() now validates each
   item's `update_type` against the GROUPS allowlist and raises
   ValueError. Without this, a typo'd entry (e.g. `external-pr` with a
   dash) would parse cleanly but never reach the rendered checklist,
   so the downstream would silently fall off the radar — exactly the
   failure mode the tracker exists to prevent.

ether#5 Traceback on YAML read/parse — main()'s except clause only caught
   ValueError, so missing-file (FileNotFoundError/OSError) and
   malformed-YAML (yaml.YAMLError) errors still surfaced as full
   tracebacks. Broaden to catch all three with a single `type(e).__name__:
   message` line so the workflow log stays actionable.

Plus three new test cases: unknown update_type, missing catalog file,
malformed YAML.

Pushing back again on:

#1/ether#3 4-space → 2-space Python indent — Python files follow PEP 8.
   Forcing 2-space breaks formatter/linter/IDE defaults and is harder
   to read than the universal Python convention. The 2-space rule is
   for JS/TS source.

ether#6 Workflow lacks feature flag — already addressed in the previous
   commit via opt-out gate `vars.SKIP_DOWNSTREAM_TRACKER`. Default
   stays opt-out because the tracker exists precisely to catch
   "forgot to enable it at release time" cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request Apr 30, 2026
Address remaining Qodo findings on the theme-color rollout:

- (#1) Skip emitting the meta entirely when settings.skinName is not
  colibris — the helper only knows colibris's --bg-color values, so
  on no-skin or third-party skins the previous code would emit a
  white meta over a non-white toolbar.
- (ether#4) Drop the prefers-color-scheme: dark variant. The pad's
  client-side dark mode is also gated on a localStorage white-mode
  override that no media query can express, so the dark meta could
  paint a dark address bar over a still-light toolbar. The single
  baseline meta always matches what the user sees on first paint.
- (ether#8) Remove the redundant module.exports assignment; rely on the
  ES named export only (tsx handles the require() interop).
- (ether#9) Iterate the toolbar variants in CSS source order and let the
  last match win, matching the cascade in pad-variants.css when
  multiple *-toolbar tokens are present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request May 1, 2026
… (ether#7636)

* feat(pad): add <meta name="theme-color"> matching toolbar (ether#7606)

Mobile browsers paint the address-bar / status-bar area above the
viewport. Without theme-color this is a system color that does not
match the Etherpad toolbar, leaving a visible gap above the pad.

Render <meta name="theme-color"> server-side so the bar matches the
configured toolbar on first paint. Light + dark variants are emitted
with prefers-color-scheme media queries when dark mode is enabled.
Colors are derived from settings.skinVariants via a new SkinColors
helper (mirrors --bg-color in the colibris pad-variants.css).

Closes ether#7606

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(timeslider): emit single theme-color matching configured toolbar

Qodo flagged a mismatch: timeslider does not switch skin variants on
prefers-color-scheme, so emitting a dark theme-color via media query
would leave dark-mode devices with a dark address bar over a light
toolbar. Drop the media-query metas on timeslider and emit one
unconditional theme-color resolved from settings.skinVariants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pad): emit unconditional theme-color so dark-OS users still match

Qodo flagged that gating the light theme-color on
prefers-color-scheme: light leaves no applicable meta on dark-OS
devices when enableDarkMode is false — the address bar then uses a
system color while the toolbar stays light.

Drop the light media query so the light theme-color is the baseline,
and let the prefers-color-scheme: dark meta override it when dark
mode is enabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(theme-color): align dark meta with client-side super-dark override

Two related Qodo findings on the SkinColors helper:

- The pad client's dark-mode auto-switch (pad.ts L650) forces
  super-dark-toolbar regardless of the configured skinVariants, so
  the prefers-color-scheme: dark meta must always be #485365 — not
  whichever dark variant the operator configured.
- When skinVariants only carries a dark token (e.g. dark-toolbar),
  the previous helper left the baseline meta at #ffffff, so light-OS
  users would see white above a dark toolbar.

Replace toolbarThemeColors() with configuredToolbarColor() (used as
the unconditional baseline) and a fixed DARK_MODE_TOOLBAR_COLOR
constant (used in the prefers-color-scheme: dark meta).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(theme-color): server-side only, drop fragile dark media query

Address remaining Qodo findings on the theme-color rollout:

- (#1) Skip emitting the meta entirely when settings.skinName is not
  colibris — the helper only knows colibris's --bg-color values, so
  on no-skin or third-party skins the previous code would emit a
  white meta over a non-white toolbar.
- (ether#4) Drop the prefers-color-scheme: dark variant. The pad's
  client-side dark mode is also gated on a localStorage white-mode
  override that no media query can express, so the dark meta could
  paint a dark address bar over a still-light toolbar. The single
  baseline meta always matches what the user sees on first paint.
- (ether#8) Remove the redundant module.exports assignment; rely on the
  ES named export only (tsx handles the require() interop).
- (ether#9) Iterate the toolbar variants in CSS source order and let the
  last match win, matching the cascade in pad-variants.css when
  multiple *-toolbar tokens are present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request May 1, 2026
Round 2 of Qodo review on ether#7601. Addressing the action-required items:

#1 Badge bypassed pad baseURL — derive basePath the same way
   padBootstrap.js does (`new URL('..', window.location.href).pathname`)
   and prefix the fetch with it. Subpath deployments now reach
   /<prefix>/api/version-status instead of 404ing.

#2 Updater poller could get stuck — `getCurrentState()` is now inside
   the try/finally so a one-time loadState() rejection can't leave
   `checkInFlight=true` and permanently silence polling.

ether#3 Updates off hung admin page — UpdatePage now self-fetches and
   renders explicit `disabled` (404), `unauthorized` (401/403), and
   `error` states instead of staying on "Loading...". Banner-driven
   prefetch is still honoured if it landed first.

ether#11 NaN polling interval — coerce `checkIntervalHours` to a number,
   clamp to [1h, 168h], log a warning and fall back to 6h on
   non-finite input. Math.max(1, NaN) === NaN previously meant a
   malformed settings.json could turn the poller into a tight loop.

ether#13 State validation accepted broken subfields — `isValid()` now
   inspects `latest.{version,tag,body,publishedAt,htmlUrl,prerelease}`,
   `vulnerableBelow[].{announcedBy,threshold}`, and
   `email.{severeAt,vulnerableAt,vulnerableNewReleaseTag}`. A
   hand-edited file with a number where a string is expected is now
   treated as corrupt and reset to EMPTY_STATE rather than crashing
   later in semver parsing or email rendering.

ether#14 Badge cache stampede — wrap `computeOutdated()` in a single-flight
   promise so concurrent requests at cache expiry await one shared
   computation instead of fanning out into N redundant disk reads.

Plus six new state.test.ts cases covering each new validation guard.

Pushing back on the remaining items:

ether#4 `updates.tier` defaults to `notify` — intentional. The whole point
   of tier 1 is to surface the "you are behind" signal to admins by
   default. Opt-in defeats the purpose; the existing failure mode
   (admin never hears about a security-relevant release) is exactly
   what this PR is fixing.

ether#5/ether#8 Admin status endpoint admin-auth — `currentVersion` is already
   public via `/health`, so wrapping the route in admin-auth doesn't
   reduce the disclosure surface meaningfully. Operators who want it
   gated set `updates.requireAdminForStatus=true` (already wired and
   covered by the comment on the route handler).

ether#10 Plain `https://` URLs in planning doc — planning markdown is
   viewed in editors and on GitHub where protocol-relative URLs would
   either render literally or break entirely. Keeping `https://`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JohnMcLear added a commit that referenced this pull request May 1, 2026
…es (ether#7601)

* docs(updater): add four-tier auto-update design spec

Four-tier opt-in self-update subsystem (off / notify / manual / auto / autonomous).
GitHub Releases as source of truth; install-method auto-detection with admin
override; in-process execution with supervisor restart; 60s drain + announce;
auto-rollback on health-check failure with crash-loop guard. Pad-side severe/
vulnerable badge that does not leak the running version. Top-level adminEmail
with escalating cadence (weekly while vulnerable, monthly while severe).

Refs: docs/superpowers/specs/2026-04-25-auto-update-design.md

* docs(updater): add PR 1 (Tier 1 notify) implementation plan

Bite-sized TDD task breakdown for shipping Tier 1 notify only:
- VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier, state modules
- /admin/update/status (admin-auth) and /api/version-status (public, no version leak)
- Admin UI banner + read-only update page + nav link
- Pad-side severe/vulnerable footer badge
- Settings: updates.* block + top-level adminEmail
- Tests: vitest unit + mocha integration + Playwright admin/pad
- CHANGELOG + doc/admin/updates.md

PRs 2-4 (manual/auto/autonomous) get their own plans after PR 1 lands.

* feat(updater): add shared types for auto-update subsystem

* feat(updater): clarify OutdatedLevel and EMPTY_STATE doc, drop path header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add semver helpers and vulnerable-below parser

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): tighten semver regex to reject four-part versions

* feat(updater): add state persistence with schema validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): reject null email and array latest in state validation

typeof null === 'object' meant {email:null} passed the old isValid check,
which would crash downstream Notifier code reading email.severeAt. Likewise,
an array would pass the typeof latest === 'object' branch. Introduce
isPlainObject helper (null-safe, Array.isArray guard) and use it for both
fields. Adds two regression tests covering the exact broken inputs.

* feat(updater): add install-method detector with override

* feat(updater): add policy evaluator

* feat(updater): add GitHub Releases checker with ETag support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): validate release fields and preserve ETag on prerelease

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add email cadence decider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): tagChanged email fires regardless of cadence; drop unused field

* feat(settings): add updates.* and adminEmail settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): wire boot hook and periodic checker

Register expressCreateServer/shutdown hooks in ep.json and implement
the boot-wiring module that detects install method, starts the polling
interval and runs the notifier dedupe pass each tick.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add /admin/update/status and /api/version-status endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n(updater): add english strings for update banner, page, and pad badge

* feat(updater): add pad footer badge for severe/vulnerable status

* feat(admin-ui): add update banner, page, and nav link

Add UpdateStatusPayload to the zustand store, a persistent UpdateBanner
rendered in the App layout, a /update page showing version details and
changelog, and a Bell nav link — all wired to the /admin/update/status
endpoint added in Task 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(updater): add Playwright specs for admin banner/page and pad badge

* docs(updater): document tier 1 settings, badge, email cadence

* refactor(updater): dedupe helpers, fix misleading log, add banner styling

- Export stateFilePath from index.ts and import it in updateStatus.ts (removes local duplicate)
- Import getEpVersion from Settings.ts in both index.ts and updateStatus.ts (removes two local definitions)
- Fix misleading 'backing off' log message — no backoff is implemented, just retries at next interval
- Remove EMPTY_STATE_FOR_TESTS re-export from state.ts; state.test.ts now imports EMPTY_STATE directly from types.ts
- Add .update-banner and .update-page CSS rules to admin/src/index.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): address review feedback — async wrap, tier=off skip, poll race, opt-in admin gate

- Wrap /api/version-status and /admin/update/status with a small async helper
  so a rejected promise becomes next(err) instead of an unhandled rejection.
- Short-circuit route registration when updates.tier === 'off' so the heavier
  opt-out also removes the HTTP surface (matches pre-PR behavior for that case).
- Add an in-flight guard around performCheck() so overlapping interval ticks
  can't race on update-state.json writes or duplicate email decisions; track
  the initial setTimeout handle and clear it in shutdown().
- Add updates.requireAdminForStatus (default false) so admins can lock
  /admin/update/status to authenticated admin sessions without disabling the
  updater. Default false preserves current behavior (the running version is
  already exposed publicly via /health). Backend specs cover unauth → 401,
  non-admin → 403, admin → 200.
- Bump admin troubleshooting menu count test 5 → 6 to account for the new
  Update nav link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(updater): address Qodo round-2 review feedback

Round 2 of Qodo review on ether#7601. Addressing the action-required items:

#1 Badge bypassed pad baseURL — derive basePath the same way
   padBootstrap.js does (`new URL('..', window.location.href).pathname`)
   and prefix the fetch with it. Subpath deployments now reach
   /<prefix>/api/version-status instead of 404ing.

#2 Updater poller could get stuck — `getCurrentState()` is now inside
   the try/finally so a one-time loadState() rejection can't leave
   `checkInFlight=true` and permanently silence polling.

ether#3 Updates off hung admin page — UpdatePage now self-fetches and
   renders explicit `disabled` (404), `unauthorized` (401/403), and
   `error` states instead of staying on "Loading...". Banner-driven
   prefetch is still honoured if it landed first.

ether#11 NaN polling interval — coerce `checkIntervalHours` to a number,
   clamp to [1h, 168h], log a warning and fall back to 6h on
   non-finite input. Math.max(1, NaN) === NaN previously meant a
   malformed settings.json could turn the poller into a tight loop.

ether#13 State validation accepted broken subfields — `isValid()` now
   inspects `latest.{version,tag,body,publishedAt,htmlUrl,prerelease}`,
   `vulnerableBelow[].{announcedBy,threshold}`, and
   `email.{severeAt,vulnerableAt,vulnerableNewReleaseTag}`. A
   hand-edited file with a number where a string is expected is now
   treated as corrupt and reset to EMPTY_STATE rather than crashing
   later in semver parsing or email rendering.

ether#14 Badge cache stampede — wrap `computeOutdated()` in a single-flight
   promise so concurrent requests at cache expiry await one shared
   computation instead of fanning out into N redundant disk reads.

Plus six new state.test.ts cases covering each new validation guard.

Pushing back on the remaining items:

ether#4 `updates.tier` defaults to `notify` — intentional. The whole point
   of tier 1 is to surface the "you are behind" signal to admins by
   default. Opt-in defeats the purpose; the existing failure mode
   (admin never hears about a security-relevant release) is exactly
   what this PR is fixing.

ether#5/ether#8 Admin status endpoint admin-auth — `currentVersion` is already
   public via `/health`, so wrapping the route in admin-auth doesn't
   reduce the disclosure surface meaningfully. Operators who want it
   gated set `updates.requireAdminForStatus=true` (already wired and
   covered by the comment on the route handler).

ether#10 Plain `https://` URLs in planning doc — planning markdown is
   viewed in editors and on GitHub where protocol-relative URLs would
   either render literally or break entirely. Keeping `https://`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@JohnMcLear
Copy link
Copy Markdown
Owner Author

Closing — the substantive a11y work (dialog semantics, focus management, icon-button conversions, html lang/dir negotiation, a11y_dialogs.spec.ts) is already on ether/etherpad develop via a separate route. The only piece not landed upstream is the export-anchor aria-label / aria-hidden cluster; opening a focused follow-up PR for just that.

@JohnMcLear JohnMcLear closed this May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants