// ==UserScript==
// @name 焦点解决
// @namespace https://greasyfork.org/zh-CN/users/1535852-severedline
// @version 3.1.1
// @description 沉浸式无障碍审查工具。
// @author qwerty
// @license Unlicense
// @match *://*/*
// @run-at document-idle
// @require https://cdn.jsdelivr.net/npm/axe-core@4.11.3/axe.min.js
// @require https://cdn.jsdelivr.net/npm/tabbable@6.4.0/dist/index.umd.min.js
// @require https://cdn.jsdelivr.net/npm/hotkeys-js@4.0.3/dist/hotkeys-js.min.js
// @grant GM_getResourceText
// @grant GM_addElement
// @grant unsafeWindow
// @resource vsrScript https://unpkg.com/@guidepup/virtual-screen-reader/lib/esm/index.browser.js
// @downloadURL https://update.greasyfork.icu/scripts/574415/%E7%84%A6%E7%82%B9%E8%A7%A3%E5%86%B3.user.js
// @updateURL https://update.greasyfork.icu/scripts/574415/%E7%84%A6%E7%82%B9%E8%A7%A3%E5%86%B3.meta.js
// ==/UserScript==
/* global axe, tabbable, hotkeys */
(function () {
'use strict';
const OVERLAY_ID = 'a11y-spotlight-overlay';
const HOLE_ID = 'a11y-spotlight-hole';
const HUD_ID = 'a11y-hud-panel';
const TOGGLE_ID = 'a11y-toggle-btn';
if (document.getElementById(OVERLAY_ID)) return;
const css = `
#${OVERLAY_ID} {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
z-index: 2147483646; pointer-events: none; overflow: hidden;
opacity: 0; visibility: hidden; transition: opacity 0.3s ease;
}
#${OVERLAY_ID}.active { visibility: visible; opacity: 1; }
#${HOLE_ID} {
position: absolute; top: 0; left: 0; box-sizing: border-box;
transition: transform 0.2s cubic-bezier(0.25, 1.2, 0.5, 1),
width 0.2s cubic-bezier(0.25, 1.2, 0.5, 1),
height 0.2s cubic-bezier(0.25, 1.2, 0.5, 1),
border-radius 0.2s ease, box-shadow 0.3s ease;
transition: transform 0.1s ease-out; will-change: transform;
}
#${HOLE_ID}.status-analyzing { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.85), 0 0 15px 2px rgba(56, 189, 248, 0.5) inset, 0 0 0 2px rgba(56, 189, 248, 0.9); }
#${HOLE_ID}.status-good { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.85), 0 0 15px 2px rgba(16, 185, 129, 0.5) inset, 0 0 0 2px rgba(16, 185, 129, 0.9); }
#${HOLE_ID}.status-warn { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.85), 0 0 15px 2px rgba(239, 68, 68, 0.5) inset, 0 0 0 2px rgba(239, 68, 68, 0.9); }
#${HOLE_ID}.status-nofocus { box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.85), 0 0 15px 2px rgba(100, 116, 139, 0.5) inset, 0 0 0 2px rgba(100, 116, 139, 0.9); }
#${HUD_ID} {
position: fixed; top: 0; left: 0; z-index: 2147483647; margin: 0;
background: rgba(15, 23, 42, 0.95); color: #e2e8f0; border-radius: 8px; padding: 10px 12px;
font-family: 'Consolas', 'Menlo', monospace; font-size: 11px;
border: 1px solid #334155; box-shadow: 0 15px 50px rgba(0,0,0,0.7);
width: 480px; max-height: 90vh; overflow-y: auto; line-height: 1.3; backdrop-filter: blur(12px);
pointer-events: auto; display: flex; flex-direction: column; gap: 4px;
transition: transform 0.1s ease-out; will-change: transform;
}
#${HUD_ID}::-webkit-scrollbar { width: 4px; }
#${HUD_ID}::-webkit-scrollbar-thumb { background: #475569; border-radius: 2px; }
#${HUD_ID} .hud-header { font-weight: bold; font-size: 12px; border-bottom: 1px solid #475569; padding-bottom: 6px; color: #38bdf8; display:flex; justify-content: space-between; align-items:center;}
#${HUD_ID} .hud-section { border-top: 1px dashed #334155; padding-top: 6px; margin-top: 2px; }
#${HUD_ID} .hud-section-title { font-size: 10px; color: #94a3b8; text-transform: uppercase; margin-bottom: 4px; letter-spacing: 0.5px;}
#${HUD_ID} .hud-row { display: flex; align-items: flex-start; word-break: break-all; margin-bottom: 2px; }
#${HUD_ID} .hud-label { color: #94a3b8; width: 60px; flex-shrink: 0; font-size: 11px; padding-top: 1px;}
#${HUD_ID} .hud-val { color: #f8fafc; flex: 1; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
#${HUD_ID} .tag-badge { color: #f472b6; font-weight: bold; font-size: 12px;}
#${HUD_ID} .id-badge { color: #818cf8; font-size: 12px;}
#${HUD_ID} .class-badge { color: #a78bfa; font-size: 12px;}
.color-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; border: 1px solid #64748b; vertical-align: middle; }
.badge { display: inline-flex; align-items: center; padding: 1px 4px; border-radius: 3px; font-size: 10px; font-weight: bold; line-height: 1.2;}
.badge.focus-yes { background: #059669; color: #fff; }
.badge.focus-no { background: #475569; color: #cbd5e1; }
.badge.focus-prog { background: #0284c7; color: #fff; }
.badge.disabled { background: #b91c1c; color: #fff; }
.badge.role-explicit { background: #8b5cf6; color: #fff; }
.badge.role-implicit { border: 1px solid #64748b; color: #94a3b8; }
.badge.aria-attr { background: #1e293b; border: 1px solid #475569; color: #38bdf8; font-family: monospace; }
.badge.aria-attr span { color: #a7f3d0; }
#${HUD_ID} .axe-violation { color: #ef4444; font-size: 12px; background: rgba(239, 68, 68, 0.1); padding: 8px; border-radius: 6px; border-left: 3px solid #ef4444; margin-bottom: 6px;}
#${HUD_ID} .axe-incomplete { color: #eab308; font-size: 12px; background: rgba(245, 158, 11, 0.1); padding: 8px; border-radius: 6px; border-left: 3px solid #eab308; margin-bottom: 6px;}
#${HUD_ID} .axe-pass { color: #10b981; font-size: 12px; background: rgba(16, 185, 129, 0.1); padding: 6px; border-radius: 6px; border-left: 3px solid #10b981; }
#${HUD_ID} .hud-analyzing { color: #38bdf8; font-size: 12px; font-style: italic; animation: a11ypulse 1.5s infinite;}
.a11y-btn { background: #38bdf8; color: #0f172a; border: none; padding: 4px 10px; border-radius: 6px; font-weight: bold; cursor: pointer; transition: 0.2s; font-size: 11px; }
.a11y-btn:hover { background: #7dd3fc; }
#${TOGGLE_ID} {
position: fixed; top: calc(100vh - 74px); left: 20px; z-index: 2147483647;
width: 54px; height: 54px; border-radius: 27px;
background: #1e293b; border: 2px solid #38bdf8; color: #38bdf8;
font-weight: bold; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transition: background 0.3s, color 0.3s, border-color 0.3s, transform 0.3s; display: flex; align-items: center; justify-content: center;
}
#${TOGGLE_ID}:hover { background: #38bdf8; color: #0f172a; transform: scale(1.08); }
#${TOGGLE_ID}.active { background: #ef4444; border-color: #ef4444; color: #fff; }
.a11y-ignore-inspect { pointer-events: auto !important; cursor: default; }
.a11y-ignore-inspect:hover { opacity: 0.15 !important; transition: opacity 0.3s ease; z-index: 2147483640 !important; }
.vsr-simulation-text {
background: linear-gradient(90deg, rgba(16,185,129,0.2) 0%, rgba(0,0,0,0) 100%);
border-left: 3px solid #10b981;
padding: 4px 8px;
font-family: 'Microsoft YaHei', sans-serif;
letter-spacing: 0.5px;
}
.a11y-focus-badge {
position: absolute; top: 0; left: 0; z-index: 2147483646;
background: #eab308; color: #000; font-weight: bold; font-size: 11px;
width: 20px; height: 20px; border-radius: 10px; border: 2px solid #fff;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.4); pointer-events: none !important;
}
#a11y-live-log {
margin-top: 8px; max-height: 120px; overflow-y: auto;
background: rgba(0,0,0,0.3); border-radius: 6px; padding: 6px;
}
.live-log-item {
font-size: 11px; padding: 4px; border-bottom: 1px solid #334155;
color: #a7f3d0; word-break: break-all;
}
.live-log-time { color: #94a3b8; margin-right: 6px; font-family: monospace; }
@keyframes a11ypulse { 0% {opacity: 1;} 50% {opacity: 0.5;} 100% {opacity: 1;} }
.a11y-landmark-box {
position: absolute; pointer-events: none !important; z-index: 2147483641;
border: 2px dashed; border-radius: 4px; background: rgba(0,0,0,0.03);
transition: opacity 0.3s ease;
}
.a11y-landmark-label {
position: absolute; top: 0; left: 0; padding: 2px 6px;
font-size: 11px; font-weight: bold; color: #fff; border-bottom-right-radius: 4px;
text-transform: uppercase; letter-spacing: 1px; backdrop-filter: blur(4px);
font-family: 'Consolas', 'Menlo', monospace;
}
.a11y-heading-label {
position: absolute; top: 0; right: 0; padding: 2px 6px;
font-size: 11px; font-weight: bold; color: #000; background: #fbbf24; border-bottom-left-radius: 4px;
text-transform: uppercase; letter-spacing: 1px; box-shadow: -2px 2px 5px rgba(0,0,0,0.2);
font-family: 'Consolas', 'Menlo', monospace;
}
.a11y-trap-container-box {
position: absolute; pointer-events: none !important; z-index: 2147483640;
border: 3px dashed #a855f7; border-radius: 8px;
background: rgba(168, 85, 247, 0.05);
box-shadow: 0 0 15px rgba(168, 85, 247, 0.3);
transition: all 0.3s ease;
}
.a11y-trap-label {
position: absolute; top: -24px; left: -3px; background: #a855f7; color: #fff;
padding: 2px 8px; font-size: 11px; font-weight: bold; border-radius: 4px 4px 0 0;
font-family: 'Consolas', monospace; display: flex; align-items: center; gap: 4px;
}
.a11y-trap-node-badge {
position: absolute; z-index: 2147483648; pointer-events: none;
padding: 2px 6px; font-size: 10px; font-weight: bold; color: #fff;
border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.5);
font-family: monospace; transform: translate(-50%, -100%); margin-top: -4px;
}
.trap-badge-first { background: #10b981; border: 1px solid #047857; }
.trap-badge-last { background: #ef4444; border: 1px solid #b91c1c; }
`;
const styleEl = document.createElement('style');
styleEl.textContent = css;
document.head.appendChild(styleEl);
const isTopWindow = window === window.top;
let overlay,
hud,
toggleBtn,
hole,
focusBadgeContainer,
ariaBadgeContainer,
svgLinesContainer,
landmarkContainer;
let customLines = [];
let landmarkBoxes = [];
let isActive = false;
let currentTarget = null;
let axeTimer = null;
let lastAxeResults = null;
let isFocusPathActive = false;
let isAxeRunning = false;
let pendingAxeElement = null;
let isLocked = false;
let isSpotlightEnabled = true;
let liveLogs = [];
let currentFocusElements = [];
let focusBadges = [];
let liveObserver = null;
let trapVisualContainer;
let currentTrapState = null;
let virtual = null;
let virtualFocusinHandlers = [];
(async () => {
try {
const rawCode = GM_getResourceText('vsrScript');
if (!rawCode) throw new Error('未找到资源 vsrScript');
const modifiedCode = rawCode
.replace(/export\s*\{[^}]+\};?/g, 'window.VirtualScreenReader = X$;')
.replace(
/async function re\(\)\{return await new Promise\(e=>setTimeout\(\(\)=>e\(\)\)\)\}/g,
'async function re(){return Promise.resolve()}'
);
GM_addElement('script', { textContent: modifiedCode });
virtual = unsafeWindow.VirtualScreenReader;
console.log('✅ Virtual Screen Reader 绕过 CSP 加载成功!');
} catch (e) {
console.error('❌ 加载 virtual-screen-reader 失败:', e);
}
})();
if (isTopWindow) {
overlay = document.createElement('div');
overlay.id = OVERLAY_ID;
overlay.innerHTML = `
`;
document.documentElement.appendChild(overlay);
hud = document.createElement('div');
hud.id = HUD_ID;
hud.style.display = 'none';
hud.addEventListener('click', (e) => {
if (e.target.closest('#a11y-btn-focus-path')) {
window.__toggleFocusPath();
} else if (e.target.closest('#a11y-btn-log-node')) {
window.__logA11yNode();
}
});
document.documentElement.appendChild(hud);
toggleBtn = document.createElement('button');
toggleBtn.id = TOGGLE_ID;
toggleBtn.innerText = 'A11y';
toggleBtn.title = '开启/关闭 无障碍审查';
toggleBtn.setAttribute('aria-pressed', 'false');
document.documentElement.appendChild(toggleBtn);
hole = document.getElementById(HOLE_ID);
focusBadgeContainer = document.createElement('div');
focusBadgeContainer.style.position = 'fixed';
focusBadgeContainer.style.top = '0';
focusBadgeContainer.style.left = '0';
focusBadgeContainer.style.width = '100%';
focusBadgeContainer.style.height = '0';
focusBadgeContainer.style.pointerEvents = 'none';
focusBadgeContainer.style.zIndex = '2147483646';
document.documentElement.appendChild(focusBadgeContainer);
ariaBadgeContainer = document.createElement('div');
ariaBadgeContainer.id = 'a11y-aria-bubbles';
ariaBadgeContainer.style.position = 'fixed';
ariaBadgeContainer.style.top = '0';
ariaBadgeContainer.style.left = '0';
ariaBadgeContainer.style.width = '100vw';
ariaBadgeContainer.style.height = '100vh';
ariaBadgeContainer.style.pointerEvents = 'none';
ariaBadgeContainer.style.zIndex = '2147483646';
document.documentElement.appendChild(ariaBadgeContainer);
svgLinesContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgLinesContainer.id = 'a11y-custom-lines';
svgLinesContainer.style.cssText =
'position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483645;overflow:visible;';
svgLinesContainer.innerHTML = `
`;
document.documentElement.appendChild(svgLinesContainer);
landmarkContainer = document.createElement('div');
landmarkContainer.id = 'a11y-landmarks-container';
landmarkContainer.style.position = 'fixed';
landmarkContainer.style.top = '0';
landmarkContainer.style.left = '0';
landmarkContainer.style.width = '100%';
landmarkContainer.style.height = '0';
landmarkContainer.style.pointerEvents = 'none';
landmarkContainer.style.zIndex = '2147483641';
document.documentElement.appendChild(landmarkContainer);
trapVisualContainer = document.createElement('div');
trapVisualContainer.id = 'a11y-trap-visuals';
trapVisualContainer.style.position = 'fixed';
trapVisualContainer.style.top = '0';
trapVisualContainer.style.left = '0';
trapVisualContainer.style.width = '100%';
trapVisualContainer.style.height = '0';
trapVisualContainer.style.pointerEvents = 'none';
trapVisualContainer.style.zIndex = '2147483642';
document.documentElement.appendChild(trapVisualContainer);
}
function analyzeFocusTrap(el) {
const container = el.closest(
'dialog, [role="dialog"], [role="alertdialog"], [aria-modal="true"]',
);
if (!container) return null;
const trapInfo = {
container: container,
firstNode: null,
lastNode: null,
totalTabbable: 0,
};
try {
const nodes = tabbable.tabbable(container, { getShadowRoot: true });
trapInfo.totalTabbable = nodes.length;
if (nodes.length > 0) {
trapInfo.firstNode = nodes[0];
trapInfo.lastNode = nodes[nodes.length - 1];
}
} catch (e) {
console.debug('Focus trap analysis error:', e);
}
return trapInfo;
}
function drawTrapVisuals(trapInfo) {
if (!isTopWindow) return;
trapVisualContainer.innerHTML = '';
if (!trapInfo) return;
const { container, firstNode, lastNode } = trapInfo;
const rect = getViewportRect(container);
if (rect.width > 0 && rect.height > 0) {
const box = document.createElement('div');
box.className = 'a11y-trap-container-box';
box.style.left = `${rect.left}px`;
box.style.top = `${rect.top}px`;
box.style.width = `${rect.width}px`;
box.style.height = `${rect.height}px`;
const label = document.createElement('div');
label.className = 'a11y-trap-label';
label.innerHTML = `🔒 Focus Trap Container (${trapInfo.totalTabbable} nodes)`;
box.appendChild(label);
trapVisualContainer.appendChild(box);
}
const drawBadge = (node, text, typeClass) => {
if (!node) return;
const nRect = getViewportRect(node);
const badge = document.createElement('div');
badge.className = `a11y-trap-node-badge ${typeClass}`;
badge.innerText = text;
badge.style.left = `${nRect.left + nRect.width / 2}px`;
badge.style.top = `${nRect.top}px`;
trapVisualContainer.appendChild(badge);
};
drawBadge(firstNode, '▶️ 第一落点', 'trap-badge-first');
if (lastNode && lastNode !== firstNode) {
drawBadge(lastNode, '⏹️ 末尾节点', 'trap-badge-last');
}
}
function updateCustomLines() {
customLines.forEach((item) => {
try {
const r1 = getViewportRect(item.sourceEl);
const r2 = getViewportRect(item.targetEl);
const inViewport1 =
r1.bottom > 0 &&
r1.top < window.innerHeight &&
r1.right > 0 &&
r1.left < window.innerWidth;
const inViewport2 =
r2.bottom > 0 &&
r2.top < window.innerHeight &&
r2.right > 0 &&
r2.left < window.innerWidth;
if (!inViewport1 && !inViewport2) {
item.pathEl.style.display = 'none';
if (item.targetBox) item.targetBox.style.display = 'none';
if (item.targetBubble) item.targetBubble.style.display = 'none';
return;
}
item.pathEl.style.display = 'block';
if (item.targetBox) item.targetBox.style.display = 'block';
if (item.targetBubble) item.targetBubble.style.display = 'block';
const x1 = r1.left + r1.width / 2;
const y1 = r1.top + r1.height / 2;
const x2 = r2.left + r2.width / 2;
const y2 = r2.top + r2.height / 2;
const dx = x2 - x1;
const cx1 = x1 + dx * 0.3;
const cy1 = y1;
const cx2 = x2 - dx * 0.3;
const cy2 = y2;
item.pathEl.setAttribute('d', `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`);
if (item.gradEl) {
item.gradEl.setAttribute('x1', x1);
item.gradEl.setAttribute('y1', y1);
item.gradEl.setAttribute('x2', x2);
item.gradEl.setAttribute('y2', y2);
}
if (item.targetBox) {
item.targetBox.style.transform = `translate(${r2.left - 6}px, ${r2.top - 6}px)`;
item.targetBox.style.width = `${r2.width + 12}px`;
item.targetBox.style.height = `${r2.height + 12}px`;
}
if (item.targetBubble) {
item.targetBubble.style.transform = `translate(${r2.left}px, ${r2.top - item.targetBubble.offsetHeight - 8}px)`;
}
} catch (e) {
console.debug(e);
}
});
}
function updateHudPosition() {
if (hud.style.display === 'none') return;
const btnRect = toggleBtn.getBoundingClientRect();
const hudRect = hud.getBoundingClientRect();
let x = btnRect.left;
let y = btnRect.top - hudRect.height - 16;
if (y < 0) y = btnRect.bottom + 16;
if (x + hudRect.width > window.innerWidth - 16) x = window.innerWidth - hudRect.width - 16;
if (x < 16) x = 16;
if (y + hudRect.height > window.innerHeight - 16) y = window.innerHeight - hudRect.height - 16;
if (y < 16) y = 16;
hud.style.transform = `translate(${x}px, ${y}px)`;
}
let hudObserver;
if (isTopWindow) {
hudObserver = new MutationObserver(() => {
if (isActive) updateHudPosition();
});
hudObserver.observe(hud, { childList: true, subtree: true, characterData: true });
}
function clearAriaVisuals() {
customLines = customLines.filter((line) => {
if (line.type === 'aria') {
line.pathEl.remove();
if (line.targetBox) line.targetBox.remove();
if (line.targetBubble) line.targetBubble.remove();
return false;
}
return true;
});
const ariaBubbleGroup = document.getElementById('a11y-aria-bubbles');
if (ariaBubbleGroup) ariaBubbleGroup.innerHTML = '';
}
function deepQuerySelectorAll(selector, root = document) {
const results = Array.from(root.querySelectorAll(selector));
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode: function (node) {
return node.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
let currentNode = walker.nextNode();
while (currentNode) {
results.push(...deepQuerySelectorAll(selector, currentNode.shadowRoot));
currentNode = walker.nextNode();
}
return results;
}
function updateLandmarks(forceRebuild = false) {
if (!isTopWindow) return;
if (!isActive) {
landmarkContainer.innerHTML = '';
landmarkBoxes = [];
return;
}
if (landmarkBoxes.length === 0 || forceRebuild) {
landmarkContainer.innerHTML = '';
landmarkBoxes = [];
const landmarkSelectors =
'main, nav, header, footer, aside, form, search, section, [role="main"], [role="navigation"],[role="banner"], [role="contentinfo"],[role="complementary"], [role="form"], [role="search"],[role="region"], [role="group"]';
const headingSelectors = 'h1, h2, h3, h4, h5, h6,[role="heading"]';
const elements = deepQuerySelectorAll(`${landmarkSelectors}, ${headingSelectors}`);
const colors = {
main: '#ec4899',
nav: '#3b82f6',
header: '#f59e0b',
footer: '#8b5cf6',
aside: '#10b981',
form: '#06b6d4',
search: '#0ea5e9',
section: '#64748b',
group: '#64748b',
};
elements.forEach((el) => {
try {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
} catch {
return;
}
let tagName = el.tagName.toLowerCase();
let role = el.getAttribute('role') || tagName;
let isHeading = /^h[1-6]$/.test(tagName) || role === 'heading';
let color = isHeading ? '#fbbf24' : colors[role] || '#94a3b8';
let labelText = role;
if (isHeading && el.hasAttribute('aria-level')) {
labelText += ` (L${el.getAttribute('aria-level')})`;
}
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) labelText += ` ("${ariaLabel}")`;
const box = document.createElement('div');
box.className = 'a11y-landmark-box';
box.style.border = isHeading ? `2px dotted ${color}` : `2px dashed ${color}`;
const label = document.createElement('div');
label.className = isHeading ? 'a11y-heading-label' : 'a11y-landmark-label';
if (!isHeading) label.style.backgroundColor = color;
label.innerText = labelText;
box.appendChild(label);
landmarkContainer.appendChild(box);
landmarkBoxes.push({ el, box });
});
}
landmarkBoxes.forEach(({ el, box }) => {
const rect = getViewportRect(el);
const inViewport = isElementInViewport(rect);
if (inViewport && rect.width > 0 && rect.height > 0) {
if (box.style.display !== 'block') box.style.display = 'block';
box.style.left = rect.left + 'px';
box.style.top = rect.top + 'px';
box.style.width = rect.width + 'px';
box.style.height = rect.height + 'px';
} else {
if (box.style.display !== 'none') box.style.display = 'none';
}
});
}
function clearFocusVisuals() {
customLines = customLines.filter((line) => {
if (line.type === 'focus') {
line.pathEl.remove();
if (line.defsEl) line.defsEl.remove();
return false;
}
return true;
});
focusBadges = [];
if (focusBadgeContainer) focusBadgeContainer.innerHTML = '';
}
function drawAriaLines(sourceEl) {
if (!isTopWindow) return;
clearAriaVisuals();
if (!sourceEl) return;
const relationships = [
{ attrs: ['aria-labelledby'], color: '#38bdf8', marker: 'blue' },
{
attrs: ['aria-describedby', 'aria-details', 'aria-errormessage'],
color: '#a855f7',
marker: 'purple',
},
{
attrs: ['aria-controls', 'aria-owns', 'aria-activedescendant'],
color: '#f97316',
marker: 'orange',
},
];
const ariaBubbleGroup = document.getElementById('a11y-aria-bubbles');
relationships.forEach((rel) => {
rel.attrs.forEach((attr) => {
if (sourceEl.hasAttribute(attr)) {
const ids = sourceEl.getAttribute(attr).split(/\s+/).filter(Boolean);
ids.forEach((id) => {
const targetEl = document.getElementById(id);
if (targetEl) {
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathEl.setAttribute('stroke', rel.color);
pathEl.setAttribute('stroke-width', '2');
pathEl.setAttribute('fill', 'none');
pathEl.setAttribute('stroke-dasharray', '4 4');
pathEl.setAttribute('marker-end', `url(#a11y-arrow-${rel.marker})`);
svgLinesContainer.appendChild(pathEl);
const targetBox = document.createElement('div');
targetBox.style.position = 'absolute';
targetBox.style.top = '0';
targetBox.style.left = '0';
targetBox.style.border = `2px dashed ${rel.color}`;
targetBox.style.backgroundColor = `${rel.color}20`;
targetBox.style.borderRadius = '8px';
targetBox.style.pointerEvents = 'none';
ariaBubbleGroup.appendChild(targetBox);
const targetBubble = document.createElement('div');
targetBubble.style.position = 'absolute';
targetBubble.style.top = '0';
targetBubble.style.left = '0';
targetBubble.style.backgroundColor = rel.color;
targetBubble.style.color = '#fff';
targetBubble.style.padding = '4px 10px';
targetBubble.style.borderRadius = '12px';
targetBubble.style.fontSize = '12px';
targetBubble.style.fontWeight = 'bold';
targetBubble.style.boxShadow =
'0 4px 10px rgba(0,0,0,0.5), 0 0 0 2px rgba(255,255,255,0.8)';
targetBubble.className = 'a11y-ignore-inspect';
targetBubble.style.maxWidth = '250px';
targetBubble.style.wordBreak = 'break-all';
targetBubble.innerText = attr;
ariaBubbleGroup.appendChild(targetBubble);
customLines.push({
type: 'aria',
sourceEl,
targetEl,
pathEl,
targetBox,
targetBubble,
});
}
});
}
});
});
updateCustomLines();
}
window.__toggleFocusPath = function () {
isFocusPathActive = !isFocusPathActive;
clearFocusVisuals();
if (!isFocusPathActive) {
drawSpotlight();
return;
}
let elements = [];
try {
elements = tabbable.focusable(document.body, { getShadowRoot: true });
} catch (e) {
console.debug(e);
}
elements = elements.filter((el) => {
if (
el.closest(`#${HUD_ID}`) ||
el.closest(`#${TOGGLE_ID}`) ||
el.closest('.a11y-ignore-inspect')
)
return false;
const rect = getViewportRect(el);
const inViewport = isElementInViewport(rect, 500);
const style = window.getComputedStyle(el);
return (
inViewport &&
rect.width > 0 &&
rect.height > 0 &&
style.visibility !== 'hidden' &&
style.display !== 'none'
);
});
if (elements.length > 120) {
elements = elements.slice(0, 120);
}
currentFocusElements = elements;
try {
let strictTabbable = tabbable.tabbable(document.body, { getShadowRoot: true });
const tabIndexMap = new Map(strictTabbable.map((el, i) => [el, i]));
elements.sort((a, b) => {
let indexA = tabIndexMap.has(a) ? tabIndexMap.get(a) : 999999;
let indexB = tabIndexMap.has(b) ? tabIndexMap.get(b) : 999999;
return indexA - indexB;
});
} catch (e) {
console.debug(e);
}
if (isTopWindow) {
let prevEl = null;
let prevColor = null;
const total = elements.length;
elements.forEach((el, index) => {
const progress = total > 1 ? index / (total - 1) : 0;
const hue = progress * 280;
const currentColor = `hsl(${hue}, 90%, 55%)`;
const badge = document.createElement('div');
badge.className = 'a11y-focus-badge';
badge.innerHTML = index + 1;
badge.style.backgroundColor = currentColor;
badge.style.borderColor = '#fff';
badge.style.color = '#000';
focusBadgeContainer.appendChild(badge);
const rect = getViewportRect(el);
const tx = rect.left - 10;
const ty = rect.top - 10;
badge.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
badge.style.opacity = '1';
focusBadges.push({ el, badge });
if (prevEl) {
try {
const gradId = `a11y-grad-${Date.now()}-${index}`;
const markerId = `a11y-marker-${Date.now()}-${index}`;
const linearGrad = document.createElementNS(
'http://www.w3.org/2000/svg',
'linearGradient',
);
linearGrad.id = gradId;
linearGrad.setAttribute('gradientUnits', 'userSpaceOnUse');
linearGrad.innerHTML = `
`;
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.id = markerId;
marker.setAttribute('viewBox', '0 0 10 10');
marker.setAttribute('refX', '8');
marker.setAttribute('refY', '5');
marker.setAttribute('markerWidth', '6');
marker.setAttribute('markerHeight', '6');
marker.setAttribute('orient', 'auto');
marker.innerHTML = ``;
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
defs.appendChild(linearGrad);
defs.appendChild(marker);
svgLinesContainer.appendChild(defs);
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathEl.setAttribute('stroke', `url(#${gradId})`);
pathEl.setAttribute('stroke-width', '2.5');
pathEl.setAttribute('fill', 'none');
pathEl.setAttribute('marker-end', `url(#${markerId})`);
svgLinesContainer.appendChild(pathEl);
customLines.push({
type: 'focus',
sourceEl: prevEl,
targetEl: el,
pathEl,
defsEl: defs,
gradEl: linearGrad,
});
} catch (e) {
console.debug(e);
}
}
prevEl = el;
prevColor = currentColor;
});
updateFocusBadges();
updateCustomLines();
}
drawSpotlight();
};
function updateFocusBadges() {
focusBadges.forEach(({ el, badge }) => {
const rect = getViewportRect(el);
const inViewport =
rect.bottom > 0 &&
rect.top < window.innerHeight &&
rect.right > 0 &&
rect.left < window.innerWidth;
if (inViewport && rect.width > 0 && rect.height > 0) {
badge.style.display = 'flex';
badge.style.transform = `translate(${Math.round(rect.left - 10)}px, ${Math.round(rect.top - 10)}px)`;
} else {
badge.style.display = 'none';
}
});
}
function setupLiveObserver(enable) {
if (!enable && liveObserver) {
liveObserver.disconnect();
return;
}
if (enable && !liveObserver) {
let liveUpdateTimer = null;
let pendingRegions = new Set();
liveObserver = new MutationObserver((mutations) => {
mutations.forEach((m) => {
let target = m.target;
if (target.nodeType === Node.TEXT_NODE) target = target.parentNode;
if (m.type === 'childList' || m.type === 'characterData') {
const liveRegion = target.closest(
'[aria-live], [role="alert"], [role="status"], [role="log"]',
);
if (liveRegion) pendingRegions.add(liveRegion);
}
});
if (pendingRegions.size > 0) {
if (liveUpdateTimer) clearTimeout(liveUpdateTimer);
liveUpdateTimer = setTimeout(() => {
const time = new Date().toLocaleTimeString();
pendingRegions.forEach((region) => {
let textChange = (region.textContent || '').trim().replace(/\s+/g, ' ');
if (!textChange) textChange = '(DOM节点变动, 无文本)';
if (
liveLogs.length > 0 &&
liveLogs[0].includes(textChange) &&
liveLogs[0].includes(time)
) {
return;
}
liveLogs.unshift(
`[${time}] ${textChange}
`,
);
});
if (liveLogs.length > 20) liveLogs.length = 20;
if (isActive) {
const logContainer = document.getElementById('a11y-live-log');
if (logContainer) logContainer.innerHTML = liveLogs.join('');
}
pendingRegions.clear();
}, 150);
}
});
liveObserver.observe(document.body, { childList: true, characterData: true, subtree: true });
}
}
window.__logA11yNode = function () {
if (!currentTarget) return;
unsafeWindow.$a11y = currentTarget;
unsafeWindow.$axe = lastAxeResults;
console.log('%c========== A11y Inspector ==========', 'color:#38bdf8; font-weight:bold;');
console.log('%cDOM 节点 ($a11y):', 'color:#10b981; font-weight:bold;', currentTarget);
if (lastAxeResults) {
console.log('%cAxe 诊断数据 ($axe):', 'color:#f472b6; font-weight:bold;', lastAxeResults);
}
console.log('%c====================================', 'color:#38bdf8; font-weight:bold;');
};
const implicitRoles = {
a: 'link',
button: 'button',
nav: 'navigation',
main: 'main',
header: 'banner',
footer: 'contentinfo',
aside: 'complementary',
search: 'search',
dialog: 'dialog',
article: 'article',
section: 'region',
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
h5: 'heading',
h6: 'heading',
ul: 'list',
ol: 'list',
li: 'listitem',
table: 'table',
tr: 'row',
th: 'columnheader',
td: 'cell',
img: 'img',
figure: 'figure',
progress: 'progressbar',
select: 'combobox',
textarea: 'textbox',
};
function inspectElement(el) {
const info = {
tag: el.tagName.toLowerCase(),
id: el.id,
className:
el.className && typeof el.className === 'string'
? el.className.trim().split(' ').join('.')
: '',
role: { explicit: el.getAttribute('role'), computed: null, implicit: null },
focus: { isFocusable: false, tabIndex: el.tabIndex, statusStr: '', class: '' },
name: { text: null, description: null, source: null },
value: (() => {
if (el.tagName === 'INPUT' && el.type === 'password') return null;
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') return el.value;
if (el.tagName === 'SELECT') return el.options[el.selectedIndex]?.text || null;
return null;
})(),
isPassword: el.tagName === 'INPUT' && el.type === 'password',
nativeChecked:
el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')
? el.checked
: null,
shortcuts: el.getAttribute('aria-keyshortcuts') || el.getAttribute('accesskey') || null,
roleDescription: el.getAttribute('aria-roledescription') || null,
level: el.getAttribute('aria-level') || null,
childCount:
['ul', 'ol', 'menu', 'tablist'].includes(el.tagName.toLowerCase()) ||
['list', 'menu', 'tablist', 'grid'].includes(el.getAttribute('role'))
? el.children.length
: null,
aria: [],
nativeAttrs: [],
states: [],
};
const standardAttrs = [
'alt',
'title',
'placeholder',
'for',
'type',
'href',
'src',
'lang',
'required',
'readonly',
];
for (let attr of standardAttrs) {
if (el.hasAttribute(attr)) {
info.nativeAttrs.push({ name: attr, value: el.getAttribute(attr) });
}
}
if (info.tag === 'input') {
const t = el.type || 'text';
info.role.implicit = ['button', 'submit', 'reset', 'image'].includes(t)
? 'button'
: ['checkbox', 'radio', 'search'].includes(t)
? t
: 'textbox';
} else {
info.role.implicit = implicitRoles[info.tag] || null;
}
if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
info.role.implicit = 'textbox';
if (!info.states.includes('multiline')) info.states.push('HTML: multiline');
}
info.role.computed = info.role.explicit || info.role.implicit;
const isNativeFocusable = [
'button',
'input',
'select',
'textarea',
'a',
'details',
'summary',
].includes(info.tag);
const isDisabled =
el.disabled ||
el.getAttribute('aria-disabled') === 'true' ||
el.closest?.('fieldset:disabled') !== null;
const isHidden =
el.getAttribute('aria-hidden') === 'true' || window.getComputedStyle(el).display === 'none';
const isInert = el.inert || el.closest?.('[inert]') !== null;
if (isInert) {
info.focus.statusStr = 'Inert (冻结/忽略/不可交互)';
info.focus.class = 'focus-no';
info.states.push('Inert');
} else if (isDisabled) {
info.focus.statusStr = 'Disabled (不可聚焦/交互)';
info.focus.class = 'disabled';
info.states.push('Disabled');
} else if (isHidden) {
info.focus.statusStr = 'Hidden (对屏幕阅读器隐藏)';
info.focus.class = 'focus-no';
info.states.push('Hidden');
} else if (el.tabIndex >= 0) {
info.focus.isFocusable = true;
info.focus.statusStr = `Keyboard Focusable (tabindex=${el.tabIndex})`;
info.focus.class = 'focus-yes';
} else if (el.tabIndex === -1 && !isNativeFocusable && el.hasAttribute('tabindex')) {
info.focus.isFocusable = true;
info.focus.statusStr = `Programmatically Focusable (tabindex=-1)`;
info.focus.class = 'focus-prog';
} else {
info.focus.statusStr = `Not Focusable`;
info.focus.class = 'focus-no';
}
if (!info.name.text) {
if (el.hasAttribute('aria-label')) {
info.name = { text: el.getAttribute('aria-label'), source: 'aria-label' };
} else if (info.tag === 'img' && el.hasAttribute('alt')) {
info.name = { text: el.getAttribute('alt'), source: 'alt' };
} else if (info.tag === 'svg' && el.querySelector('title')) {
info.name = { text: el.querySelector('title').textContent, source: '' };
} else if (['input', 'textarea', 'select'].includes(info.tag) && el.id) {
const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
if (label) info.name = { text: label.textContent.trim(), source: '