fix: improve accessibility to meet WCAG 2.2 AA/AAA standards

- Remove disruptive alert() on error pages, rely on role="alert" banner
- Add skip navigation link for keyboard users (WCAG 2.4.1)
- Fix heading hierarchy on error page (sr-only status text in h1)
- Add "opens in a new tab" screen reader hint on external links
- Wrap language selector in form with noscript submit button (WCAG 3.2.2)
- Add aria-label to copy-to-clipboard buttons and footer landmarks
- Use h2 for alt-IP section to maintain single h1 per page
- Add persistent underline on links for non-color identification (WCAG 1.4.1)
- Fix footer attribution contrast ratio to ~5.5:1 (WCAG 1.4.3)
- Add aria-busy/role=status loading announcements for skeleton state
- Add translations for new accessibility strings across all 14 locales
- Update integration tests to assert role="alert" instead of alert()
This commit is contained in:
NetworkCats
2026-03-22 18:21:29 +08:00
parent b76410abf4
commit 991fd289a2
23 changed files with 281 additions and 31 deletions
+12
View File
@@ -191,3 +191,15 @@ msgstr "فشل عرض القالب"
msgid "Copied"
msgstr "تم النسخ"
msgid "Go"
msgstr "انتقال"
msgid "opens in a new tab"
msgstr "يفتح في علامة تبويب جديدة"
msgid "Copy IP address to clipboard"
msgstr "نسخ عنوان IP إلى الحافظة"
msgid "Site footer"
msgstr "تذييل الموقع"
+12
View File
@@ -191,3 +191,15 @@ msgstr "Vorlagen-Rendering fehlgeschlagen"
msgid "Copied"
msgstr "Kopiert"
msgid "Go"
msgstr "Los"
msgid "opens in a new tab"
msgstr "öffnet in neuem Tab"
msgid "Copy IP address to clipboard"
msgstr "IP-Adresse in die Zwischenablage kopieren"
msgid "Site footer"
msgstr "Seitenfuß"
+12
View File
@@ -191,3 +191,15 @@ msgstr "Template render failed"
msgid "Copied"
msgstr "Copied"
msgid "Go"
msgstr "Go"
msgid "opens in a new tab"
msgstr "opens in a new tab"
msgid "Copy IP address to clipboard"
msgstr "Copy IP address to clipboard"
msgid "Site footer"
msgstr "Site footer"
+12
View File
@@ -191,3 +191,15 @@ msgstr "La renderizacion de la plantilla fallo"
msgid "Copied"
msgstr "Copiado"
msgid "Go"
msgstr "Ir"
msgid "opens in a new tab"
msgstr "se abre en una pestaña nueva"
msgid "Copy IP address to clipboard"
msgstr "Copiar dirección IP al portapapeles"
msgid "Site footer"
msgstr "Pie de página"
+12
View File
@@ -191,3 +191,15 @@ msgstr "Echec du rendu du modele"
msgid "Copied"
msgstr "Copie"
msgid "Go"
msgstr "Aller"
msgid "opens in a new tab"
msgstr "s'ouvre dans un nouvel onglet"
msgid "Copy IP address to clipboard"
msgstr "Copier l'adresse IP dans le presse-papiers"
msgid "Site footer"
msgstr "Pied de page"
+12
View File
@@ -191,3 +191,15 @@ msgstr "Render template gagal"
msgid "Copied"
msgstr "Disalin"
msgid "Go"
msgstr "Buka"
msgid "opens in a new tab"
msgstr "dibuka di tab baru"
msgid "Copy IP address to clipboard"
msgstr "Salin alamat IP ke papan klip"
msgid "Site footer"
msgstr "Footer situs"
+12
View File
@@ -191,3 +191,15 @@ msgstr "Rendering del template fallito"
msgid "Copied"
msgstr "Copiato"
msgid "Go"
msgstr "Vai"
msgid "opens in a new tab"
msgstr "si apre in una nuova scheda"
msgid "Copy IP address to clipboard"
msgstr "Copia l'indirizzo IP negli appunti"
msgid "Site footer"
msgstr "Piè di pagina"
+12
View File
@@ -191,3 +191,15 @@ msgstr "テンプレートのレンダリングに失敗しました"
msgid "Copied"
msgstr "コピーしました"
msgid "Go"
msgstr "移動"
msgid "opens in a new tab"
msgstr "新しいタブで開きます"
msgid "Copy IP address to clipboard"
msgstr "IPアドレスをクリップボードにコピー"
msgid "Site footer"
msgstr "サイトフッター"
+12
View File
@@ -191,3 +191,15 @@ msgstr "템플릿 렌더링 실패"
msgid "Copied"
msgstr "복사됨"
msgid "Go"
msgstr "이동"
msgid "opens in a new tab"
msgstr "새 탭에서 열림"
msgid "Copy IP address to clipboard"
msgstr "IP 주소를 클립보드에 복사"
msgid "Site footer"
msgstr "사이트 바닥글"
+16
View File
@@ -221,3 +221,19 @@ msgstr ""
#: templates/base.html - language selector label
msgid "Language"
msgstr ""
#: templates/_footer_lang.html - language submit button (noscript)
msgid "Go"
msgstr ""
#: templates/index.html - ASN link new tab hint
msgid "opens in a new tab"
msgstr ""
#: templates/index.html - copy IP button label
msgid "Copy IP address to clipboard"
msgstr ""
#: templates/index.html, error_alert.html - footer landmark label
msgid "Site footer"
msgstr ""
+12
View File
@@ -191,3 +191,15 @@ msgstr "Sjabloonweergave mislukt"
msgid "Copied"
msgstr "Gekopieerd"
msgid "Go"
msgstr "Ga"
msgid "opens in a new tab"
msgstr "opent in een nieuw tabblad"
msgid "Copy IP address to clipboard"
msgstr "IP-adres kopiëren naar klembord"
msgid "Site footer"
msgstr "Sitefooter"
+12
View File
@@ -191,3 +191,15 @@ msgstr "Falha na renderização do template"
msgid "Copied"
msgstr "Copiado"
msgid "Go"
msgstr "Ir"
msgid "opens in a new tab"
msgstr "abre em uma nova aba"
msgid "Copy IP address to clipboard"
msgstr "Copiar endereço IP para a área de transferência"
msgid "Site footer"
msgstr "Rodapé do site"
+12
View File
@@ -191,3 +191,15 @@ msgstr "Ошибка рендеринга шаблона"
msgid "Copied"
msgstr "Скопировано"
msgid "Go"
msgstr "Перейти"
msgid "opens in a new tab"
msgstr "откроется в новой вкладке"
msgid "Copy IP address to clipboard"
msgstr "Скопировать IP-адрес в буфер обмена"
msgid "Site footer"
msgstr "Подвал сайта"
+12
View File
@@ -191,3 +191,15 @@ msgstr "模板渲染失败"
msgid "Copied"
msgstr "已复制"
msgid "Go"
msgstr "前往"
msgid "opens in a new tab"
msgstr "在新标签页中打开"
msgid "Copy IP address to clipboard"
msgstr "复制 IP 地址到剪贴板"
msgid "Site footer"
msgstr "网站页脚"
+12
View File
@@ -191,3 +191,15 @@ msgstr "範本渲染失敗"
msgid "Copied"
msgstr "已複製"
msgid "Go"
msgstr "前往"
msgid "opens in a new tab"
msgstr "在新分頁中開啟"
msgid "Copy IP address to clipboard"
msgstr "複製 IP 位址到剪貼簿"
msgid "Site footer"
msgstr "網站頁尾"
+22 -4
View File
@@ -177,6 +177,8 @@ function initCopyIp(wrap) {
wrap.style.cursor = "pointer";
wrap.setAttribute("role", "button");
wrap.setAttribute("tabindex", "0");
const copyLabel = wrap.getAttribute("data-copy-label") || "Copy IP address to clipboard";
wrap.setAttribute("aria-label", copyLabel);
const copyIp = () => {
const ip = h1.textContent.trim();
@@ -282,10 +284,11 @@ function initAltIp(root, section) {
const copyWrap = document.createElement("div");
copyWrap.className = "ip-copy-wrap";
copyWrap.setAttribute("data-copied", copiedText);
const h1 = document.createElement("h1");
h1.className = "ip-display";
h1.textContent = ip;
copyWrap.appendChild(h1);
copyWrap.setAttribute("data-copy-label", root.querySelector(".ip-copy-wrap")?.getAttribute("data-copy-label") || "Copy IP address to clipboard");
const h2 = document.createElement("h2");
h2.className = "ip-display";
h2.textContent = ip;
copyWrap.appendChild(h2);
const tooltip = document.createElement("span");
tooltip.className = "ip-copied-tooltip";
tooltip.setAttribute("aria-live", "polite");
@@ -314,6 +317,10 @@ function initAltIp(root, section) {
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = `AS${asnNum}`;
const newTabHint = document.createElement("span");
newTabHint.className = "sr-only";
newTabHint.textContent = ` (opens in a new tab)`;
a.appendChild(newTabHint);
td.appendChild(a);
addRow(tbody, labelFor("asn"), td);
}
@@ -345,14 +352,24 @@ function initAltIp(root, section) {
}
function showSkeleton() {
section.setAttribute("aria-busy", "true");
section.setAttribute("aria-label", "Loading additional IP information");
section.setAttribute("role", "status");
section.innerHTML = buildSkeleton();
section.hidden = false;
section.classList.add("alt-visible");
}
function clearLoadingState() {
section.removeAttribute("aria-busy");
section.removeAttribute("aria-label");
section.removeAttribute("role");
}
function showAltIp(ip, info) {
section.classList.remove("alt-visible");
setTimeout(() => {
clearLoadingState();
section.replaceChildren(buildBlock(ip, info));
initCopyIp(section.querySelector(".ip-copy-wrap"));
scaleIpv6(section.querySelector(".ip-display"));
@@ -372,6 +389,7 @@ function initAltIp(root, section) {
function hideSkeleton() {
section.classList.remove("alt-visible");
setTimeout(() => {
clearLoadingState();
section.replaceChildren();
section.hidden = true;
}, 300);
+56 -8
View File
@@ -81,6 +81,25 @@ main {
border: 0;
}
.skip-link {
position: absolute;
inset-inline-start: -9999px;
inset-block-start: 0;
z-index: 10000;
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
color: #FFF;
background: var(--color-blue);
border-radius: 0 0 6px 6px;
text-decoration: none;
&:focus {
inset-inline-start: 50%;
translate: -50% 0;
}
}
.search-form {
display: flex;
@@ -207,10 +226,12 @@ main {
a {
color: var(--color-link);
text-decoration: none;
text-decoration: underline;
text-decoration-color: light-dark(rgb(0 122 255 / 0.4), rgb(9 132 255 / 0.4));
text-underline-offset: 2px;
&:hover {
text-decoration: underline;
text-decoration-color: currentColor;
}
}
}
@@ -267,10 +288,12 @@ main {
a {
color: var(--color-link);
text-decoration: none;
text-decoration: underline;
text-decoration-color: light-dark(rgb(0 122 255 / 0.4), rgb(9 132 255 / 0.4));
text-underline-offset: 2px;
&:hover {
text-decoration: underline;
text-decoration-color: currentColor;
}
}
@@ -406,10 +429,12 @@ main {
a {
color: var(--color-link);
text-decoration: none;
text-decoration: underline;
text-decoration-color: light-dark(rgb(0 122 255 / 0.4), rgb(9 132 255 / 0.4));
text-underline-offset: 2px;
&:hover {
text-decoration: underline;
text-decoration-color: currentColor;
}
}
@@ -430,11 +455,11 @@ main {
p {
margin-block-end: 6px;
font-size: 11px;
color: var(--color-gray2);
color: light-dark(#6E6E73, #9A9A9F);
}
a {
color: var(--color-gray2);
color: light-dark(#6E6E73, #9A9A9F);
font-size: 11px;
}
@@ -449,6 +474,9 @@ main {
.footer-lang {
margin-block-start: 0;
display: flex;
gap: 6px;
align-items: center;
}
.lang-select {
@@ -470,6 +498,26 @@ main {
outline-offset: -1px;
}
}
.lang-submit {
padding: 6px 12px;
border: 1px solid var(--color-separator);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--color-label-secondary);
background: var(--color-bg);
cursor: pointer;
&:hover {
background: var(--color-bg-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: -1px;
}
}
}
[dir="rtl"] .lang-select {
+4 -3
View File
@@ -1,8 +1,9 @@
<div class="footer-lang">
<form class="footer-lang" method="GET" action="/{{ lang_tag }}">
<label for="lang-select" class="sr-only">{{ "Language"|t(locale) }}</label>
<select id="lang-select" class="lang-select" autocomplete="off" onchange="if(this.value){var u=new(window.URL)(this.value,window.location.origin);u.search=window.location.search;window.location.href=u.href}">
<select id="lang-select" name="lang" class="lang-select" autocomplete="off" onchange="if(this.value){var u=new(window.URL)(this.value,window.location.origin);u.search=window.location.search;window.location.href=u.href}">
{% for l in all_locales %}
<option value="/{{ l.tag() }}"{% if *l == locale %} selected{% endif %}>{{ l.name() }}</option>
{% endfor %}
</select>
</div>
<noscript><button type="submit" class="lang-submit">{{ "Go"|t(locale) }}</button></noscript>
</form>
+2 -1
View File
@@ -19,7 +19,8 @@
{% block head_extra %}{% endblock %}
</head>
<body>
<main>
<a href="#main-content" class="skip-link">Skip to main content</a>
<main id="main-content">
{% block content %}{% endblock %}
</main>
{% block footer %}{% endblock %}
+2 -4
View File
@@ -14,9 +14,7 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/simple-line-icons/2.5.5/css/simple-line-icons.min.css">
{% endblock %}
{% block head_extra %}
<script>alert("{{ error_message }}");</script>
{% endblock %}
{% block head_extra %}{% endblock %}
{% block content %}
{% let autofocus = true %}
@@ -32,7 +30,7 @@
{% endblock %}
{% block footer %}
<footer class="site-footer">
<footer class="site-footer" aria-label="{{ "Site footer"|t(locale) }}">
<div class="footer-inner">
<div class="footer-left">
{% include "_footer.html" %}
+2 -2
View File
@@ -8,8 +8,8 @@
{% block content %}
<div class="error-page">
<h1 class="error-code">{{ status_code }}</h1>
<p class="error-status">{{ status_text }}</p>
<h1 class="error-code">{{ status_code }}<span class="sr-only"> - {{ status_text }}</span></h1>
<p class="error-status" aria-hidden="true">{{ status_text }}</p>
<p class="error-detail">{{ error_message }}</p>
<a href="/{{ locale.tag() }}" class="error-home-link">{{ "Go to Home"|t(locale) }}</a>
</div>
+3 -3
View File
@@ -72,7 +72,7 @@
data-table-label="{{ "IP address information"|t(locale) }}"
{% if is_query %}data-is-query="true"{% endif %}
id="ip-info-root">
<div class="ip-copy-wrap" data-copied="{{ "Copied"|t(locale) }}">
<div class="ip-copy-wrap" data-copied="{{ "Copied"|t(locale) }}" data-copy-label="{{ "Copy IP address to clipboard"|t(locale) }}">
<h1 class="ip-display" itemprop="name">{{ ip }}</h1>
<span class="ip-copied-tooltip" aria-live="polite" hidden>{{ "Copied"|t(locale) }}</span>
</div>
@@ -82,7 +82,7 @@
{% if !asn.is_empty() %}
<tr data-label="asn">
<th scope="row">{{ "ASN"|t(locale) }}</th>
<td>{% if let Some(n) = asn_number %}<a href="https://bgp.tools/as/{{ n }}" target="_blank" rel="noopener noreferrer">{{ asn }}</a>{% else %}{{ asn }}{% endif %}</td>
<td>{% if let Some(n) = asn_number %}<a href="https://bgp.tools/as/{{ n }}" target="_blank" rel="noopener noreferrer">{{ asn }}<span class="sr-only"> ({{ "opens in a new tab"|t(locale) }})</span></a>{% else %}{{ asn }}{% endif %}</td>
</tr>
{% endif %}
{% if !org.is_empty() %}
@@ -274,7 +274,7 @@ curl {{ site_domain }}/tor</code></pre>
{% endblock %}
{% block footer %}
<footer class="site-footer">
<footer class="site-footer" aria-label="{{ "Site footer"|t(locale) }}">
<div class="footer-inner">
<div class="footer-left">
{% include "_footer.html" %}
+6 -6
View File
@@ -916,7 +916,7 @@ async fn browser_invalid_ip_shows_alert_html() {
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("alert("));
assert!(body.contains("role=\"alert\""));
assert!(body.contains("not-an-ip"));
}
@@ -934,7 +934,7 @@ async fn browser_non_public_ip_shows_alert_html() {
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("alert("));
assert!(body.contains("role=\"alert\""));
}
#[tokio::test]
@@ -951,7 +951,7 @@ async fn browser_missing_client_ip_shows_alert_html() {
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("alert("));
assert!(body.contains("role=\"alert\""));
}
#[tokio::test]
@@ -1367,7 +1367,7 @@ async fn browser_private_ipv4_shows_error() {
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("alert("));
assert!(body.contains("role=\"alert\""));
assert!(body.contains("192.168.1.1"));
}
@@ -1384,7 +1384,7 @@ async fn browser_loopback_shows_error() {
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("alert("));
assert!(body.contains("role=\"alert\""));
}
// --- IPv4 domain tests ---
@@ -1582,5 +1582,5 @@ async fn browser_broadcast_shows_error() {
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("alert("));
assert!(body.contains("role=\"alert\""));
}