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(" ", "")