From 5e2c20586e30c982845367527b87d939e07be3e6 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:54:15 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EB=A7=81=ED=81=AC=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20UI=20=EB=9E=98=ED=95=91=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `target="_blank"` 속성을 가지는 외부 링크에 스크린 리더용 숨김 텍스트(`.sr-only`)와 시각적 안내 아이콘 추가. - 긴 URL로 인해 레이아웃이 깨지는 현상을 방지하기 위해 `.ext-link` 클래스 적용 (`word-break: break-all`). --- .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..594d0f027 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-02 - Accessible External Links +**Learning:** Providing external links that open in a new tab without explicit accessible warnings (such as `.sr-only` text) or visual cues (such as an `aria-hidden` icon) causes confusion for assistive technology users. Relying globally on `word-break` for anchor tags breaks layout, whereas scoped CSS allows safe word wrapping for URLs. +**Action:** Add `.sr-only` descriptive text, a visual SVG icon, and a scoped CSS class (e.g., `.ext-link`) to `target="_blank"` links. diff --git a/scanner/dashboard/index.html b/scanner/dashboard/index.html index 132bc31b2..5660188d0 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..3289f3f08 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_are_accessible(): + """External links must expose assistive technology and visual warnings.""" + html = dashboard_index_path().read_text(encoding="utf-8") + + match = re.search(r"const refs = .*?map\(r=>`(.*?)`\)\.join", html) + assert match is not None, "Could not find references template literal" + literal = match.group(1) + + class ExtLinkParser(HTMLParser): + def __init__(self): + super().__init__() + self.has_sr_only = False + self.has_aria_hidden_icon = False + self.link_classes = [] + + def handle_starttag(self, tag, attrs): + attrs_dict = dict(attrs) + if tag == "a" and attrs_dict.get("target") == "_blank": + classes = attrs_dict.get("class", "") + self.link_classes = classes.split() + elif tag == "span" and "sr-only" in attrs_dict.get("class", ""): + self.has_sr_only = True + elif tag == "svg" and attrs_dict.get("aria-hidden") == "true": + self.has_aria_hidden_icon = True + + parser = ExtLinkParser() + parser.feed(literal) + + assert parser.has_sr_only, "Missing .sr-only assistive technology warning" + assert parser.has_aria_hidden_icon, "Missing aria-hidden true visual warning icon" + assert "ext-link" in parser.link_classes, "Missing scoped class for word-break on long URLs" + + assert ".ext-link{word-break:break-all}" in html.replace(" ", "") + assert "a{word-break" not in html.replace(" ", "") From ae0911d2bfc3dd3f274fc5c027a55ed51daa92bd Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:21:21 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EB=A7=81=ED=81=AC=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `target="_blank"` 외부 링크에 `.sr-only` 텍스트와 시각적 아이콘(SVG) 추가. - 긴 URL 줄바꿈 문제를 해결하기 위해 `.ext-link` 클래스 적용. From 3e479d8dc94bcea7e7b71114bbb7a13f0266d60f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 3 Oct 2026 09:02:45 +0900 Subject: [PATCH 3/3] chore(review): retire duplicate external-link proposal Restore the protected tree while preserving the proposal in ordinary ancestry. Canonical #1361 carries the complete product behavior with safer overflow-wrap:anywhere, noopener, assistive text, a hidden decorative cue, and a focused regression; #1364 carries CHANGELOG and Gap evidence. The word-break:break-all variant and generated guidance add no remaining valid delta. --- .jules/palette.md | 4 ---- scanner/dashboard/index.html | 3 +-- tests/test_dashboard_core.py | 36 ------------------------------------ 3 files changed, 1 insertion(+), 42 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index 594d0f027..ea004e2d2 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -81,7 +81,3 @@ ## 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-02 - Accessible External Links -**Learning:** Providing external links that open in a new tab without explicit accessible warnings (such as `.sr-only` text) or visual cues (such as an `aria-hidden` icon) causes confusion for assistive technology users. Relying globally on `word-break` for anchor tags breaks layout, whereas scoped CSS allows safe word wrapping for URLs. -**Action:** Add `.sr-only` descriptive text, a visual SVG icon, and a scoped CSS class (e.g., `.ext-link`) to `target="_blank"` links. diff --git a/scanner/dashboard/index.html b/scanner/dashboard/index.html index 5660188d0..132bc31b2 100644 --- a/scanner/dashboard/index.html +++ b/scanner/dashboard/index.html @@ -82,7 +82,6 @@ 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 @@ -282,7 +281,7 @@

Dashboard

function openDetail(f){ lastFocus = document.activeElement; const s = String(f.severity||'INFO').toUpperCase(); - const refs = (f.references||[]).map(r=>`${esc(r)} (opens in a new tab)`).join('
'); + const refs = (f.references||[]).map(r=>`${esc(r)}`).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 3289f3f08..75a5809f8 100644 --- a/tests/test_dashboard_core.py +++ b/tests/test_dashboard_core.py @@ -302,39 +302,3 @@ 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_are_accessible(): - """External links must expose assistive technology and visual warnings.""" - html = dashboard_index_path().read_text(encoding="utf-8") - - match = re.search(r"const refs = .*?map\(r=>`(.*?)`\)\.join", html) - assert match is not None, "Could not find references template literal" - literal = match.group(1) - - class ExtLinkParser(HTMLParser): - def __init__(self): - super().__init__() - self.has_sr_only = False - self.has_aria_hidden_icon = False - self.link_classes = [] - - def handle_starttag(self, tag, attrs): - attrs_dict = dict(attrs) - if tag == "a" and attrs_dict.get("target") == "_blank": - classes = attrs_dict.get("class", "") - self.link_classes = classes.split() - elif tag == "span" and "sr-only" in attrs_dict.get("class", ""): - self.has_sr_only = True - elif tag == "svg" and attrs_dict.get("aria-hidden") == "true": - self.has_aria_hidden_icon = True - - parser = ExtLinkParser() - parser.feed(literal) - - assert parser.has_sr_only, "Missing .sr-only assistive technology warning" - assert parser.has_aria_hidden_icon, "Missing aria-hidden true visual warning icon" - assert "ext-link" in parser.link_classes, "Missing scoped class for word-break on long URLs" - - assert ".ext-link{word-break:break-all}" in html.replace(" ", "") - assert "a{word-break" not in html.replace(" ", "")