From 113ad8c7f1a49470225be28b35958a32a86f5808 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:08:07 +0000 Subject: [PATCH] feat(dashboard): improve accessibility of external links Add visually hidden warning text for screen readers and an aria-hidden SVG icon for sighted users to target="_blank" links in the dashboard findings dialog. Prevent layout breaks with a scoped word-break utility class. --- .jules/palette.md | 4 ++++ scanner/dashboard/index.html | 3 ++- tests/test_dashboard_core.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.jules/palette.md b/.jules/palette.md index ea004e2d2..ebcb95ead 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -81,3 +81,7 @@ ## 2026-08-12 - Skip to Content Accessibility **Learning:** Screen reader and keyboard-only users experience significant friction when forced to navigate through repetitive header controls on every page load. **Action:** Keep a visible-on-focus skip link as the first interactive element, target a programmatically focusable main container, and give the focused link a high-contrast outline. + +## 2026-10-06 - 외부 링크 접근성 개선 +**Learning:** `target="_blank"` 속성을 가진 외부 링크는 스크린 리더 사용자에게 새 창에서 열린다는 사실을 알려주지 않으면 불편함을 초래할 수 있습니다. +**Action:** 외부 링크 텍스트 내에 `.sr-only` 텍스트(예: `(새 창에서 열림)`)를 추가하고 시각적으로는 `aria-hidden="true"` 아이콘을 추가하여 접근성을 향상시키며, 긴 URL 텍스트가 아이콘과 분리되어 레이아웃을 깨뜨리지 않도록 대상 링크 요소에만 `word-break: break-all`이 적용되는 유틸리티 클래스(`.ext-link`)를 추가합니다. diff --git a/scanner/dashboard/index.html b/scanner/dashboard/index.html index 132bc31b2..71deaaa43 100644 --- a/scanner/dashboard/index.html +++ b/scanner/dashboard/index.html @@ -82,6 +82,7 @@ body.drag-active::after{content:"Drop findings.json here";position:fixed;inset:0;z-index:99;background:rgba(37,110,244,.08);border:4px dashed var(--primary);display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;color:var(--primary);pointer-events:none;backdrop-filter:blur(2px)} .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} a{color:var(--primary)} + .ext-link{word-break:break-all} .skip-link{position:absolute;top:-48px;left:0;background:var(--primary);color:#fff;padding:8px 16px;z-index:100;font-weight:700;border-radius:0 0 8px 0;transition:top .2s}\n .skip-link:focus{top:0;outline:3px solid #fff;outline-offset:2px;box-shadow:0 0 0 5px var(--primary)}\n @@ -281,7 +282,7 @@

Dashboard

function openDetail(f){ lastFocus = document.activeElement; const s = String(f.severity||'INFO').toUpperCase(); - const refs = (f.references||[]).map(r=>`${esc(r)}`).join('
'); + const refs = (f.references||[]).map(r=>`${esc(r)} (opens in a new tab)`).join('
'); const owasp = (f.owasp||[]).join(', '); const cwe = (f.cwe||[]).join(', '); const d = document.getElementById('detail'); diff --git a/tests/test_dashboard_core.py b/tests/test_dashboard_core.py index 75a5809f8..334f5ebb5 100644 --- a/tests/test_dashboard_core.py +++ b/tests/test_dashboard_core.py @@ -302,3 +302,39 @@ def test_dashboard_search_escape_clears_input(): assert "e.key === 'Escape'" in html assert "query = '';" in html assert "render();" in html + +def test_dashboard_external_links_have_accessibility_warnings(): + """External target=_blank links must expose a screen reader warning and visual icon.""" + html = dashboard_index_path().read_text(encoding="utf-8") + + import re + from html.parser import HTMLParser + + match = re.search(r"const refs = [^`]*`(.*?)`\)\.join", html) + assert match is not None + link_html = match.group(1) + + class LinkParser(HTMLParser): + def __init__(self): + super().__init__() + self.has_sr_only = False + self.has_aria_hidden = False + self.link_attrs = {} + + def handle_starttag(self, tag, attrs): + attrs_dict = dict(attrs) + if tag == "a": + self.link_attrs = attrs_dict + elif tag == "span" and attrs_dict.get("class") == "sr-only": + self.has_sr_only = True + elif tag == "svg" and attrs_dict.get("aria-hidden") == "true": + self.has_aria_hidden = True + + safe_html = re.sub(r"\$\{.*?\}", "DUMMY", link_html) + parser = LinkParser() + parser.feed(safe_html) + + assert parser.link_attrs.get("target") == "_blank" + assert parser.link_attrs.get("class") == "ext-link" + assert parser.has_sr_only + assert parser.has_aria_hidden