Skip to content
Draft
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-08-16 - Accessible Native File Input Proxy
**Learning:** `aria-hidden="true"`๋Š” ํŒŒ์ผ ์ž…๋ ฅ ์š”์†Œ๋ฅผ ์ ‘๊ทผ์„ฑ ํŠธ๋ฆฌ์—์„œ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๋„ค์ดํ‹ฐ๋ธŒ `<input type="file">` ๋Œ€์‹  ๋ช…์‹œ์ ์ธ ๋ฒ„ํŠผ์„ ๋…ธ์ถœํ•˜๊ณ  ํ”„๋ก์‹œ๋กœ ๋™์ž‘ํ•˜๋„๋ก ํ•˜๋ฉด ์ ‘๊ทผ์„ฑ์ด ํ–ฅ์ƒ๋ฉ๋‹ˆ๋‹ค.
**Action:** ์•ž์œผ๋กœ ๋„ค์ดํ‹ฐ๋ธŒ ํŒŒ์ผ ์ž…๋ ฅ์„ ์ˆจ๊ธธ ๋•Œ ์ด ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
8 changes: 7 additions & 1 deletion scanner/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@
<span class="logo"></span><span class="brand">AppGuardrail</span>
<span class="spacer"></span>
<span class="meta" id="src">no findings loaded</span>
<input type="file" id="file" accept="application/json,.json" aria-label="Upload findings file" style="margin-left:12px">
<input type="file" id="file" accept="application/json,.json" class="sr-only" tabindex="-1" aria-hidden="true">
<button type="button" id="upload-proxy" class="primary-action" style="margin-left:12px">Upload findings</button>
</header>
<p id="findings-summary" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></p>
<main id="app" tabindex="-1"></main>
Expand Down Expand Up @@ -388,6 +389,11 @@ <h1>Dashboard</h1>
if(isDeployBlocking(f)!==expect) console.error('selftest FAIL', f, 'expected', expect);
}
})();

(function() {
const btn = document.getElementById('upload-proxy');
if(btn) btn.addEventListener('click', () => document.getElementById('file').click());
})();
</script>
</body>
</html>
80 changes: 73 additions & 7 deletions tests/test_dashboard_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import json as _json
import re
import subprocess
import threading
import urllib.error
import urllib.request
Expand All @@ -16,18 +17,22 @@
make_dashboard_server, render_tokens_css)


class _ButtonAttributeParser(HTMLParser):
"""Collect attributes from every dashboard button element."""
class _DashboardControlParser(HTMLParser):
"""Collect attributes from dashboard buttons and inputs."""

def __init__(self):
"""Initialize an empty button-attribute collection."""
"""Initialize empty dashboard-control collections."""
super().__init__()
self.buttons = []
self.inputs = []

def handle_starttag(self, tag, attrs):
"""Record one button's attributes while ignoring other elements."""
"""Record button and input attributes while ignoring other elements."""
attributes = dict(attrs)
if tag == "button":
self.buttons.append(dict(attrs))
self.buttons.append(attributes)
elif tag == "input":
self.inputs.append(attributes)


def _serve(server):
Expand Down Expand Up @@ -77,13 +82,74 @@ def test_dashboard_rows_are_keyboard_accessible():
assert 'tabindex="0" role="button"' in html
assert 'title="View details for finding"' in html
assert "tbody tr:focus-visible" in html
assert "aria-label=\"Upload findings file\"" in html
assert "aria-label=\"Search findings\"" in html
assert "aria-label=\"Filter by severity\"" in html
assert "tr.addEventListener('keydown'" in html
assert "e.key === 'Enter' || e.key === ' '" in html


def test_dashboard_upload_proxy_hides_input_and_delegates_click():
"""The visible upload button must own the native input's complete contract."""
html = dashboard_index_path().read_text(encoding="utf-8")
parser = _DashboardControlParser()
parser.feed(html)

file_input = next(
attributes for attributes in parser.inputs if attributes.get("id") == "file"
)
proxy_button = next(
attributes
for attributes in parser.buttons
if attributes.get("id") == "upload-proxy"
)

assert file_input.get("type") == "file"
assert "sr-only" in file_input.get("class", "").split()
assert file_input.get("tabindex") == "-1"
assert file_input.get("aria-hidden") == "true"
assert proxy_button.get("type") == "button"
assert "primary-action" in proxy_button.get("class", "").split()
assert "Upload findings</button>" in html

bridge_match = re.search(
r"\(function\(\) \{\s*"
r"const btn = document\.getElementById\('upload-proxy'\);"
r".*?\}\)\(\);",
html,
flags=re.DOTALL,
)
assert bridge_match is not None

node_program = """
let delegatedClicks = 0;
let clickHandler = null;
const uploadInput = {click() { delegatedClicks += 1; }};
const proxyButton = {
addEventListener(eventName, handler) {
if (eventName === 'click') clickHandler = handler;
}
};
const document = {
getElementById(elementId) {
if (elementId === 'upload-proxy') return proxyButton;
if (elementId === 'file') return uploadInput;
return null;
}
};
""" + bridge_match.group(0) + """
if (typeof clickHandler !== 'function') throw new Error('click handler missing');
clickHandler();
if (delegatedClicks !== 1) throw new Error('file input click not delegated');
"""
result = subprocess.run(
["node", "-e", node_program],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr


def test_dashboard_severity_cards_are_accessible_filter_toggles():
"""Severity cards must preserve their complete pointer and keyboard contract."""
html = dashboard_index_path().read_text(encoding="utf-8")
Expand Down Expand Up @@ -285,7 +351,7 @@ def test_dashboard_dialog_close_button_has_tooltip():
)
assert detail_markup is not None

parser = _ButtonAttributeParser()
parser = _DashboardControlParser()
parser.feed(detail_markup.group("markup"))

assert any(
Expand Down
Loading