diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 41b2df7..9550408 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -90,6 +90,31 @@ jobs: retention-days: 2 if-no-files-found: error + a11y: + name: Verify Theme Manager accessibility + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + package-manager-cache: false + + - name: Install accessibility test dependencies + run: npm ci --prefix apps/theme-manager + + - name: Assert Theme Manager accessibility in Chromium + env: + ACT_CHROME_BIN: google-chrome + run: npm run a11y:check --prefix apps/theme-manager + desktop: name: Desktop ${{ matrix.os }} ${{ matrix.rust-target || 'native' }} needs: prepare diff --git a/apps/theme-manager/package-lock.json b/apps/theme-manager/package-lock.json index 27b1108..1683e21 100644 --- a/apps/theme-manager/package-lock.json +++ b/apps/theme-manager/package-lock.json @@ -9,7 +9,8 @@ "version": "0.3.2", "license": "MIT", "devDependencies": { - "@tauri-apps/cli": "2.11.4" + "@tauri-apps/cli": "2.11.4", + "axe-core": "4.12.1" } }, "node_modules/@tauri-apps/cli": { @@ -243,6 +244,16 @@ "engines": { "node": ">= 10" } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } } } } diff --git a/apps/theme-manager/package.json b/apps/theme-manager/package.json index eec8925..539b313 100644 --- a/apps/theme-manager/package.json +++ b/apps/theme-manager/package.json @@ -7,6 +7,7 @@ "scripts": { "tauri": "tauri", "prepare:assets": "node ../../scripts/prepare-desktop.mjs && tauri icon build/icon.png --output src-tauri/icons", + "a11y:check": "node scripts/verify-a11y.mjs", "start": "npm run prepare:assets && tauri dev", "pretest": "npm run prepare:assets", "test": "cargo test --manifest-path src-tauri/Cargo.toml", @@ -18,7 +19,8 @@ "build:mac:release": "npm run prepare:assets && tauri build --config src-tauri/tauri.release.conf.json --bundles dmg" }, "devDependencies": { - "@tauri-apps/cli": "2.11.4" + "@tauri-apps/cli": "2.11.4", + "axe-core": "4.12.1" }, "author": "Awesome Codex Theme contributors", "license": "MIT" diff --git a/apps/theme-manager/scripts/verify-a11y.mjs b/apps/theme-manager/scripts/verify-a11y.mjs new file mode 100644 index 0000000..e2e30ae --- /dev/null +++ b/apps/theme-manager/scripts/verify-a11y.mjs @@ -0,0 +1,605 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, ".."); +const RENDERER_ROOT = path.join(APP_ROOT, "src", "renderer"); +const AXE_SOURCE_PATH = path.join(APP_ROOT, "node_modules", "axe-core", "axe.min.js"); +const DEFAULT_TIMEOUT_MS = 30_000; +const PROCESS_STOP_TIMEOUT_MS = 2_000; + +const dataImage = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1600' height='900'%3E%3Crect width='100%25' height='100%25' fill='%23246A4B'/%3E%3C/svg%3E"; +const fixtureTheme = { + id: "a11y-fixture", + collection: "fixture", + rightsProfile: "original", + variant: "cinematic", + name: { en: "Accessibility Fixture", "zh-CN": "无障碍测试主题" }, + tagline: { en: "Keyboard and dialog fixture", "zh-CN": "键盘与对话框测试" }, + description: { en: "A local renderer fixture for accessibility checks.", "zh-CN": "用于无障碍检查的本地渲染器测试主题。" }, + tags: ["fixture"], + previews: { + light: { + imageUrl: dataImage, + capture: { appVersion: "fixture", sha256: "a".repeat(64) }, + fullSkin: { testedVersion: "fixture" }, + }, + dark: { + imageUrl: dataImage, + capture: { appVersion: "fixture", sha256: "b".repeat(64) }, + fullSkin: { testedVersion: "fixture" }, + }, + }, +}; +const fixtureThemeTwo = { + ...fixtureTheme, + id: "a11y-fixture-second", + name: { en: "Second Accessibility Fixture", "zh-CN": "第二个无障碍测试主题" }, + tagline: { en: "Keyboard selection fixture", "zh-CN": "键盘选择测试" }, +}; +const fixtureCatalog = { + catalogRevision: 1, + registrySha256: "c".repeat(64), + source: "bundled", + collections: [{ id: "fixture", name: { en: "Fixture", "zh-CN": "测试" } }], + themes: [fixtureTheme, fixtureThemeTwo], +}; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function testBridgeSource() { + const bootstrap = { + appVersion: "0.0.0-a11y", + platform: "win32", + targets: [{ channel: "beta", label: "ChatGPT Beta", version: "fixture" }], + skinState: { active: true, themeId: fixtureTheme.id, mode: "light", channel: "beta" }, + persistenceState: { enabled: false, phase: "disabled" }, + updateState: { status: "development" }, + catalogState: { status: "bundled" }, + catalog: fixtureCatalog, + }; + return ` +const fixtureCatalog = ${JSON.stringify(fixtureCatalog)}; +const fixtureBootstrap = ${JSON.stringify(bootstrap)}; +const clone = (value) => JSON.parse(JSON.stringify(value)); +window.__ACT_A11Y__ = { calls: [] }; +const disabledPersistence = () => ({ enabled: false, phase: "disabled" }); +let persistence = disabledPersistence(); +async function invoke(command, args) { + window.__ACT_A11Y__.calls.push({ command, args }); + switch (command) { + case "bootstrap": return clone(fixtureBootstrap); + case "refresh_catalog": return { status: "current", catalog: clone(fixtureCatalog) }; + case "get_skin_state": + case "restore_full_skin": return { active: false }; + case "get_persistence_state": return clone(persistence); + case "enable_persistent_theme": + persistence = { + enabled: true, + phase: "active", + themeId: args.themeId, + mode: args.mode, + channel: args.channel, + autostartEnabled: true, + }; + return clone(persistence); + case "disable_persistent_theme": + persistence = disabledPersistence(); + return clone(persistence); + case "check_for_app_update": return { status: "development" }; + case "copy_theme": + case "open_external": + case "install_app_update": return null; + default: throw new Error("Unexpected accessibility fixture command: " + command); + } +} +window.__TAURI__ = { + core: { convertFileSrc: (value) => value, invoke }, + event: { listen: async () => () => {} }, + window: { + getCurrentWindow: () => ({ + close: async () => {}, + minimize: async () => {}, + startDragging: async () => {}, + toggleMaximize: async () => {}, + }), + }, +}; +if (new URL(window.location.href).searchParams.has("fallback")) { + Object.defineProperty(window.HTMLDialogElement.prototype, "showModal", { configurable: true, value: undefined }); + Object.defineProperty(window.HTMLDialogElement.prototype, "close", { configurable: true, value: undefined }); +} +`; +} + +function contentType(file) { + const types = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + }; + return types[path.extname(file).toLowerCase()] || "application/octet-stream"; +} + +async function startFixtureServer() { + const axeSource = await readFile(AXE_SOURCE_PATH, "utf8").catch(() => { + throw new Error("axe-core is unavailable. Run npm ci --prefix apps/theme-manager first."); + }); + const server = createServer(async (request, response) => { + try { + const requestUrl = new URL(request.url || "/", "http://127.0.0.1"); + if (requestUrl.pathname === "/test-bridge.js") { + response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" }); + response.end(testBridgeSource()); + return; + } + if (requestUrl.pathname === "/axe.min.js") { + response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" }); + response.end(axeSource); + return; + } + const relative = requestUrl.pathname === "/" ? "index.html" : requestUrl.pathname.replace(/^\/+/, ""); + const candidate = path.resolve(RENDERER_ROOT, decodeURIComponent(relative)); + const boundary = RENDERER_ROOT.endsWith(path.sep) ? RENDERER_ROOT : RENDERER_ROOT + path.sep; + if (!candidate.startsWith(boundary)) { + response.writeHead(403); + response.end("Forbidden"); + return; + } + const info = await stat(candidate); + if (!info.isFile()) throw new Error("Fixture path is not a file"); + let body = await readFile(candidate); + if (path.basename(candidate) === "index.html") { + const marker = ''; + const html = body.toString("utf8"); + if (!html.includes(marker)) throw new Error("Renderer entrypoint marker is missing"); + body = Buffer.from(html.replace(marker, '\n ' + marker)); + } + response.writeHead(200, { "Content-Type": contentType(candidate), "Cache-Control": "no-store" }); + response.end(body); + } catch { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not start the accessibility fixture server"); + return { server, url: "http://127.0.0.1:" + address.port + "/" }; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function waitFor(description, probe, timeoutMs = DEFAULT_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + while (Date.now() < deadline) { + try { + const value = await probe(); + if (value) return value; + } catch (error) { + lastError = error; + } + await sleep(100); + } + const detail = lastError ? ": " + lastError.message : ""; + throw new Error("Timed out waiting for " + description + detail); +} + +function defaultBrowserCommand() { + if (process.env.ACT_CHROME_BIN) return process.env.ACT_CHROME_BIN; + return process.platform === "win32" + ? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" + : "google-chrome"; +} + +function startBrowser(profile) { + const child = spawn(defaultBrowserCommand(), [ + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--disable-dev-shm-usage", + "--remote-allow-origins=*", + "--remote-debugging-port=0", + "--user-data-dir=" + profile, + "about:blank", + ], { stdio: ["ignore", "ignore", "pipe"], windowsHide: true }); + let startupError = null; + let stderr = ""; + child.once("error", (error) => { + startupError = error; + }); + child.stderr.on("data", (chunk) => { + stderr = (stderr + chunk).slice(-4_000); + }); + return { + child, + assertRunning() { + if (startupError) throw new Error("Chromium could not start: " + startupError.message); + if (child.exitCode !== null) throw new Error("Chromium exited early" + (stderr ? ": " + stderr.trim() : "")); + }, + }; +} + +async function stopBrowser(browser) { + if (!browser?.child) return; + const { child } = browser; + const exited = () => child.exitCode !== null || child.signalCode !== null; + const waitForExit = async () => { + if (exited()) return; + await Promise.race([ + new Promise((resolve) => child.once("exit", resolve)), + sleep(PROCESS_STOP_TIMEOUT_MS), + ]); + }; + if (!exited()) { + child.kill(); + await waitForExit(); + } + if (!exited()) { + child.kill("SIGKILL"); + await waitForExit(); + } +} + +class DevToolsSession { + constructor(socket) { + this.socket = socket; + this.nextId = 1; + this.pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (!message.id) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(message.error.message)); + else pending.resolve(message.result || {}); + }); + socket.addEventListener("close", () => { + for (const pending of this.pending.values()) pending.reject(new Error("DevTools connection closed")); + this.pending.clear(); + }); + } + + send(method, params = {}) { + const id = this.nextId; + this.nextId += 1; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + close() { + this.socket.close(); + } +} + +async function connectDevTools(webSocketDebuggerUrl) { + const socket = new WebSocket(webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", () => reject(new Error("Could not connect to Chromium DevTools")), { once: true }); + }); + return new DevToolsSession(socket); +} + +async function evaluate(session, expression) { + const result = await session.send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || "Runtime evaluation failed"); + return result.result?.value; +} + +async function pressKey(session, key, code, windowsVirtualKeyCode, modifiers = 0, text = "") { + await session.send("Input.dispatchKeyEvent", { + type: "keyDown", + key, + code, + text, + unmodifiedText: text, + modifiers, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); + await session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key, + code, + modifiers, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); +} + +async function pressTab(session) { + await session.send("Input.dispatchKeyEvent", { + type: "rawKeyDown", + key: "Tab", + code: "Tab", + windowsVirtualKeyCode: 9, + nativeVirtualKeyCode: 9, + }); + await session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Tab", + code: "Tab", + windowsVirtualKeyCode: 9, + nativeVirtualKeyCode: 9, + }); +} + +async function pressCopyShortcut(session) { + await session.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Control", + code: "ControlLeft", + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); + await pressKey(session, "c", "KeyC", 67, 2, "c"); + await session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Control", + code: "ControlLeft", + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); +} + +async function pressCommandEnter(session) { + await session.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Control", + code: "ControlLeft", + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); + await pressKey(session, "Enter", "Enter", 13, 2, "\r"); + await session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Control", + code: "ControlLeft", + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); +} + +async function loadAxe(session, siteUrl) { + await evaluate(session, `new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = ${JSON.stringify(siteUrl + "axe.min.js")}; + script.addEventListener("load", () => resolve(true), { once: true }); + script.addEventListener("error", () => reject(new Error("Could not load axe-core")), { once: true }); + document.head.append(script); + })`); +} + +async function assertNoSeriousAxeViolations(session) { + const violations = await evaluate(session, `window.axe.run(document, { resultTypes: ["violations"] }) + .then(({ violations }) => violations + .filter(({ impact }) => impact === "serious" || impact === "critical") + .map(({ id, impact, nodes }) => ({ id, impact, targets: nodes.map(({ target }) => target.join(" ")).slice(0, 3) })))`); + if (violations?.length) { + throw new Error("axe found serious or critical violations: " + JSON.stringify(violations)); + } +} + +async function main() { + const { server, url } = await startFixtureServer(); + const profile = await mkdtemp(path.join(os.tmpdir(), "act-theme-manager-a11y-")); + const browser = startBrowser(profile); + let session = null; + + try { + await waitFor("Chromium DevTools", async () => { + browser.assertRunning(); + const activePort = await readFile(path.join(profile, "DevToolsActivePort"), "utf8").catch(() => null); + const port = Number(activePort?.split(/\r?\n/, 1)[0]); + return Number.isInteger(port) && port > 0 ? port : null; + }); + const debugPort = Number((await readFile(path.join(profile, "DevToolsActivePort"), "utf8")).split(/\r?\n/, 1)[0]); + const targetResponse = await fetch( + "http://127.0.0.1:" + debugPort + "/json/new?" + encodeURIComponent("about:blank"), + { method: "PUT" }, + ); + if (!targetResponse.ok) throw new Error("Chromium could not create an accessibility target"); + const target = await targetResponse.json(); + if (!target.webSocketDebuggerUrl) throw new Error("Chromium target did not provide a DevTools endpoint"); + + session = await connectDevTools(target.webSocketDebuggerUrl); + await Promise.all([ + session.send("Page.enable"), + session.send("Runtime.enable"), + ]); + await session.send("Page.navigate", { url }); + await waitFor("the Theme Manager accessibility fixture", async () => evaluate(session, `(() => { + const theme = document.querySelector("[data-theme-id]"); + return Boolean(theme && document.querySelector("#applySkin") && window.__ACT_A11Y__); + })()`)); + + const semantics = await evaluate(session, `(() => ({ + dialog: document.querySelector("#persistenceConsentDialog")?.tagName, + compositeRoles: document.querySelectorAll("[role='tablist'], [role='listbox']").length, + firstTheme: document.querySelector("[data-theme-id]")?.tagName, + firstThemePressed: document.querySelector("[data-theme-id]")?.getAttribute("aria-pressed"), + }))()`); + if (semantics?.dialog !== "DIALOG" || semantics.compositeRoles !== 0 + || semantics.firstTheme !== "BUTTON" || semantics.firstThemePressed !== "true") { + throw new Error("Theme Manager selector semantics are not accessible: " + JSON.stringify(semantics)); + } + + await loadAxe(session, url); + await assertNoSeriousAxeViolations(session); + + const selectedSecondTheme = await evaluate(session, `(() => { + const theme = document.querySelector("[data-theme-id='a11y-fixture-second']"); + theme?.focus(); + return document.activeElement === theme; + })()`); + if (!selectedSecondTheme) throw new Error("A theme selector is not keyboard focusable"); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("keyboard theme selection", async () => evaluate( + session, + `document.querySelector("[data-theme-id='a11y-fixture-second']")?.getAttribute("aria-pressed") === "true"`, + )); + + await evaluate(session, `document.querySelector("#search")?.focus()`); + await pressCommandEnter(session); + const inputShortcutOpenedDialog = await evaluate(session, `document.querySelector("#persistenceConsentDialog")?.open === true`); + if (inputShortcutOpenedDialog) throw new Error("Ctrl/Cmd+Enter applied a theme while an editable control was focused"); + + const focusedApply = await evaluate(session, `(() => { + const apply = document.querySelector("#applySkin"); + apply?.focus(); + return document.activeElement === apply; + })()`); + if (!focusedApply) throw new Error("The primary apply action is not keyboard focusable"); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("the native persistence dialog", async () => evaluate( + session, + `document.querySelector("#persistenceConsentDialog")?.open === true`, + )); + await assertNoSeriousAxeViolations(session); + await pressKey(session, "Escape", "Escape", 27); + await waitFor("Escape to close and restore dialog focus", async () => evaluate( + session, + `(() => !document.querySelector("#persistenceConsentDialog")?.open && document.activeElement?.id === "applySkin")()`, + )); + + await session.send("Page.navigate", { url: url + "?confirm=1" }); + await waitFor("the native confirmation fixture", async () => evaluate(session, `(() => { + const apply = document.querySelector("#applySkin"); + return Boolean(apply && window.__ACT_A11Y__ && !document.querySelector("#persistenceConsentDialog")?.open); + })()`)); + await evaluate(session, `document.querySelector("#applySkin")?.focus()`); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("the native confirmation dialog", async () => evaluate( + session, + `document.querySelector("#persistenceConsentDialog")?.open === true`, + )); + await evaluate(session, `document.querySelector("#persistenceConsentConfirm")?.focus()`); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + let confirmationState = null; + try { + await waitFor("keyboard application confirmation", async () => { + confirmationState = await evaluate(session, `(() => { + const applied = window.__ACT_A11Y__.calls.some(({ command }) => command === "enable_persistent_theme"); + return applied + && !document.querySelector("#persistenceConsentDialog")?.open + && document.querySelector("#persistenceStatus")?.textContent?.trim().length > 0; + })()`); + return confirmationState; + }); + } catch (error) { + throw new Error(error.message + ": " + JSON.stringify(confirmationState)); + } + + const copyBefore = await evaluate(session, `(() => { + const status = document.querySelector("#skinStatus"); + const range = document.createRange(); + range.selectNodeContents(status); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + return window.__ACT_A11Y__.calls.filter(({ command }) => command === "copy_theme").length; + })()`); + await pressCopyShortcut(session); + const copyAfter = await evaluate(session, `(() => window.__ACT_A11Y__.calls + .filter(({ command }) => command === "copy_theme").length)()`); + if (copyAfter !== copyBefore) { + throw new Error("Ctrl/Cmd+C intercepted selected diagnostic text instead of preserving native copy"); + } + + const restoreBefore = await evaluate(session, `(() => { + const restore = document.querySelector("#restoreSkin"); + restore?.focus(); + return { + focused: document.activeElement === restore, + calls: window.__ACT_A11Y__.calls.filter(({ command }) => command === "restore_full_skin").length, + }; + })()`); + if (!restoreBefore?.focused) throw new Error("The Restore action is not keyboard focusable"); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("keyboard restore activation", async () => evaluate( + session, + `window.__ACT_A11Y__.calls.filter(({ command }) => command === "restore_full_skin").length === ${restoreBefore.calls + 1}`, + )); + + await session.send("Emulation.setEmulatedMedia", { + features: [ + { name: "forced-colors", value: "active" }, + { name: "prefers-reduced-motion", value: "reduce" }, + ], + }); + await session.send("Page.navigate", { url: url + "?fallback=1" }); + await waitFor("the fallback consent fixture", async () => evaluate(session, `(() => { + const apply = document.querySelector("#applySkin"); + return Boolean( + apply + && window.__ACT_A11Y__ + && !document.querySelector("#persistenceConsentDialog")?.open + && matchMedia("(forced-colors: active)").matches + && matchMedia("(prefers-reduced-motion: reduce)").matches, + ); + })()`)); + await loadAxe(session, url); + await assertNoSeriousAxeViolations(session); + await evaluate(session, `document.querySelector("#applySkin")?.focus()`); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("the fallback consent dialog", async () => evaluate( + session, + `(() => { + const dialog = document.querySelector("#persistenceConsentDialog"); + return dialog?.dataset.fallback === "true" + && dialog.open === true + && document.querySelector(".app-shell")?.getAttribute("aria-hidden") === "true"; + })()`, + )); + await assertNoSeriousAxeViolations(session); + await pressTab(session); + await pressTab(session); + const fallbackFocusLooped = await evaluate(session, `document.activeElement?.id === "persistenceConsentCancel"`); + if (!fallbackFocusLooped) throw new Error("Fallback dialog did not retain Tab focus within its controls"); + await pressKey(session, "Escape", "Escape", 27); + await waitFor("fallback Escape to restore dialog focus", async () => evaluate( + session, + `(() => { + const dialog = document.querySelector("#persistenceConsentDialog"); + return !dialog?.open + && !dialog?.dataset.fallback + && !document.querySelector(".app-shell")?.hasAttribute("aria-hidden") + && document.activeElement?.id === "applySkin"; + })()`, + )); + + console.log("Theme Manager accessibility check passed: native and fallback dialog focus, keyboard select/apply/cancel/restore, shortcut context, forced-colors/reduced-motion, and axe serious/critical scans."); + } finally { + session?.close(); + await stopBrowser(browser); + await new Promise((resolve) => server.close(resolve)); + await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 }); + } +} + +try { + await main(); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/apps/theme-manager/src/renderer/app.js b/apps/theme-manager/src/renderer/app.js index 917c80d..d0ca1e8 100644 --- a/apps/theme-manager/src/renderer/app.js +++ b/apps/theme-manager/src/renderer/app.js @@ -1,4 +1,5 @@ import "./bridge.js"; +import { canUseGlobalActionShortcut } from "./shortcuts.js"; const translations = { en: { @@ -287,7 +288,6 @@ const elements = { applySkinLabel: document.querySelector("#applySkinLabel"), applyShortcut: document.querySelector("#applyShortcut"), copyTheme: document.querySelector("#copyTheme"), - copyShortcut: document.querySelector("#copyShortcut"), targetSelect: document.querySelector("#targetSelect"), restoreSkin: document.querySelector("#restoreSkin"), persistenceControl: document.querySelector("#persistenceControl"), @@ -301,6 +301,7 @@ const elements = { openRepository: document.querySelector("#openRepository"), updateButton: document.querySelector("#updateButton"), updateLabel: document.querySelector("#updateLabel"), + persistenceConsentBackdrop: document.querySelector("#persistenceConsentBackdrop"), persistenceConsentDialog: document.querySelector("#persistenceConsentDialog"), persistenceConsentCancel: document.querySelector("#persistenceConsentCancel"), persistenceConsentConfirm: document.querySelector("#persistenceConsentConfirm"), @@ -308,19 +309,95 @@ const elements = { }; let consentResolver = null; +let consentTrigger = null; +let consentUsesNativeDialog = false; +const FALLBACK_FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(", "); + +function supportsNativeDialog() { + return typeof elements.persistenceConsentDialog.showModal === "function" + && typeof elements.persistenceConsentDialog.close === "function"; +} + +function setFallbackBackgroundInert(inert) { + if ("inert" in elements.appShell) elements.appShell.inert = inert; + if (inert) elements.appShell.setAttribute("aria-hidden", "true"); + else elements.appShell.removeAttribute("aria-hidden"); +} + +function openConsentDialog() { + consentUsesNativeDialog = supportsNativeDialog(); + if (consentUsesNativeDialog) { + elements.persistenceConsentDialog.showModal(); + return; + } + elements.persistenceConsentDialog.dataset.fallback = "true"; + elements.persistenceConsentBackdrop.hidden = false; + setFallbackBackgroundInert(true); + elements.persistenceConsentDialog.setAttribute("open", ""); +} + +function closeConsentDialog() { + if (consentUsesNativeDialog) { + if (elements.persistenceConsentDialog.open) elements.persistenceConsentDialog.close(); + } else { + elements.persistenceConsentDialog.removeAttribute("open"); + delete elements.persistenceConsentDialog.dataset.fallback; + elements.persistenceConsentBackdrop.hidden = true; + setFallbackBackgroundInert(false); + } + consentUsesNativeDialog = false; +} + +function trapFallbackConsentFocus(event) { + if (!elements.persistenceConsentDialog.dataset.fallback) return; + if (event.key === "Escape") { + event.preventDefault(); + finishPersistenceConsent(false); + return; + } + if (event.key !== "Tab") return; + const focusable = [...elements.persistenceConsentDialog.querySelectorAll(FALLBACK_FOCUSABLE_SELECTOR)] + .filter((node) => !node.hidden && node.getAttribute("aria-hidden") !== "true" && node.offsetParent !== null); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +} + +function restoreConsentTrigger() { + const trigger = consentTrigger; + consentTrigger = null; + if (trigger instanceof HTMLElement && trigger.isConnected) { + trigger.focus({ preventScroll: true }); + } +} function finishPersistenceConsent(confirmed) { - if (!consentResolver) return; const resolve = consentResolver; consentResolver = null; - elements.persistenceConsentDialog.hidden = true; - resolve(confirmed); + closeConsentDialog(); + restoreConsentTrigger(); + resolve?.(confirmed); } -function requestPersistenceConsent() { - if (consentResolver) return Promise.resolve(false); - elements.persistenceConsentDialog.hidden = false; - elements.persistenceConsentConfirm.focus(); +function requestPersistenceConsent(trigger) { + if (consentResolver || elements.persistenceConsentDialog.open) return Promise.resolve(false); + consentTrigger = trigger instanceof HTMLElement ? trigger : document.activeElement; + openConsentDialog(); + elements.persistenceConsentCancel.focus({ preventScroll: true }); return new Promise((resolve) => { consentResolver = resolve; }); @@ -460,9 +537,8 @@ function renderStyleTabs() { styleDefinitions.forEach(([id, labelKey, symbol]) => { const button = document.createElement("button"); button.type = "button"; - button.role = "tab"; button.dataset.style = id; - button.setAttribute("aria-selected", String(id === state.style)); + button.setAttribute("aria-pressed", String(id === state.style)); button.classList.toggle("is-active", id === state.style); const icon = document.createElement("span"); @@ -494,9 +570,8 @@ function renderCollectionTabs() { records.forEach((collection) => { const button = document.createElement("button"); button.type = "button"; - button.role = "tab"; button.dataset.collection = collection.id; - button.setAttribute("aria-selected", String(collection.id === state.collection)); + button.setAttribute("aria-pressed", String(collection.id === state.collection)); button.classList.toggle("is-active", collection.id === state.collection); const label = document.createElement("span"); label.textContent = localized(collection.name); @@ -561,9 +636,8 @@ function renderThemeList() { themes.forEach((theme) => { const button = document.createElement("button"); button.type = "button"; - button.role = "option"; button.dataset.themeId = theme.id; - button.setAttribute("aria-selected", String(theme.id === state.themeId)); + button.setAttribute("aria-pressed", String(theme.id === state.themeId)); button.classList.toggle("is-active", theme.id === state.themeId); const thumb = document.createElement("span"); @@ -630,7 +704,7 @@ function renderSelectedTheme() { elements.themeList.querySelectorAll("[data-theme-id]").forEach((button) => { const active = button.dataset.themeId === state.themeId; button.classList.toggle("is-active", active); - button.setAttribute("aria-selected", String(active)); + button.setAttribute("aria-pressed", String(active)); }); renderSkinState(); renderPersistenceState(); @@ -750,7 +824,6 @@ function renderUpdateState() { function renderShortcuts() { const modifier = state.platform === "darwin" ? "⌘" : "Ctrl"; elements.searchShortcut.textContent = modifier + " K"; - elements.copyShortcut.textContent = modifier + " C"; elements.applyShortcut.textContent = modifier + " ↵"; } @@ -931,7 +1004,7 @@ elements.copyTheme.addEventListener("click", async () => { elements.applySkin.addEventListener("click", async () => { const channel = elements.targetSelect.value; if (!channel || !state.themeId) return; - if (!state.persistenceState?.enabled && !await requestPersistenceConsent()) return; + if (!state.persistenceState?.enabled && !await requestPersistenceConsent(elements.applySkin)) return; elements.applySkin.disabled = true; elements.applySkinLabel.textContent = t("applyingSkin"); try { @@ -990,7 +1063,7 @@ elements.persistenceToggle.addEventListener("change", async () => { renderPersistenceState(); return; } - if (enable && !await requestPersistenceConsent()) { + if (enable && !await requestPersistenceConsent(elements.persistenceToggle)) { renderPersistenceState(); return; } @@ -1011,15 +1084,16 @@ elements.persistenceToggle.addEventListener("change", async () => { elements.persistenceConsentCancel.addEventListener("click", () => finishPersistenceConsent(false)); elements.persistenceConsentConfirm.addEventListener("click", () => finishPersistenceConsent(true)); -elements.persistenceConsentDialog.addEventListener("click", (event) => { - if (event.target === elements.persistenceConsentDialog) finishPersistenceConsent(false); +elements.persistenceConsentDialog.addEventListener("cancel", (event) => { + if (!consentUsesNativeDialog) return; + event.preventDefault(); + finishPersistenceConsent(false); }); -document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && !elements.persistenceConsentDialog.hidden) { - event.preventDefault(); - finishPersistenceConsent(false); - } +elements.persistenceConsentDialog.addEventListener("close", () => { + if (consentUsesNativeDialog && consentResolver) finishPersistenceConsent(false); }); +elements.persistenceConsentBackdrop.addEventListener("click", () => finishPersistenceConsent(false)); +document.addEventListener("keydown", trapFallbackConsentFocus); elements.openGallery.addEventListener("click", () => window.act.openExternal("gallery")); elements.openRepository.addEventListener("click", () => window.act.openExternal("repository")); @@ -1037,16 +1111,18 @@ elements.updateButton.addEventListener("click", async () => { }); document.addEventListener("keydown", (event) => { + if (event.defaultPrevented) return; const command = event.metaKey || event.ctrlKey; - if (command && event.key.toLowerCase() === "k") { + const key = event.key.toLowerCase(); + if (command && key === "k" && !elements.persistenceConsentDialog.open) { event.preventDefault(); elements.search.focus(); } - if (command && event.key.toLowerCase() === "c" && document.activeElement !== elements.search) { - event.preventDefault(); - elements.copyTheme.click(); - } - if (command && event.key === "Enter") { + if (command && key === "enter" && canUseGlobalActionShortcut({ + dialogOpen: elements.persistenceConsentDialog.open, + selection: window.getSelection(), + target: document.activeElement, + })) { event.preventDefault(); elements.applySkin.click(); } diff --git a/apps/theme-manager/src/renderer/index.html b/apps/theme-manager/src/renderer/index.html index 7001fdb..2e96636 100644 --- a/apps/theme-manager/src/renderer/index.html +++ b/apps/theme-manager/src/renderer/index.html @@ -74,13 +74,13 @@
没有找到符合条件的主题。
@@ -163,7 +163,7 @@主题包只含声明式配置与图片。管理器通过仅限本机回环的临时调试会话加载背景,不修改 WindowsApps、app.asar 或 ChatGPT 私有数据。
@@ -196,14 +196,13 @@ - +