Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
3 changes: 2 additions & 1 deletion scanner/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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</style>
<!-- Canonical design tokens (scanner/dashboard/tokens.json) served at /tokens.css;
loaded last so served values override the inline fallback above. -->
Expand Down Expand Up @@ -281,7 +282,7 @@ <h1>Dashboard</h1>
function openDetail(f){
lastFocus = document.activeElement;
const s = String(f.severity||'INFO').toUpperCase();
const refs = (f.references||[]).map(r=>`<a href="${esc(safeUrl(r))}" target="_blank" rel="noopener">${esc(r)}</a>`).join('<br>');
const refs = (f.references||[]).map(r=>`<a class="ext-link" href="${esc(safeUrl(r))}" target="_blank" rel="noopener">${esc(r)}<span class="sr-only"> (opens in a new tab)</span><svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;vertical-align:-2px"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></a>`).join('<br>');
const owasp = (f.owasp||[]).join(', ');
const cwe = (f.cwe||[]).join(', ');
const d = document.getElementById('detail');
Expand Down
36 changes: 36 additions & 0 deletions tests/test_dashboard_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading