From cb8f944f39561d6f09b4c944147f83059c971ad3 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:59:45 +0000 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[UX=20improveme?= =?UTF-8?q?nt]=20HTML=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `role="status"`를 사용하여 비어있는 상태(empty-state)가 스크린 리더에서 올바르게 읽히도록 개선 - 숫자 데이터 가독성 향상을 위해 테이블 셀에 `font-variant-numeric: tabular-nums;` 적용 - 테이블 행(row)에 대한 hover 격리(isolation) 및 트랜지션 적용으로 시각적 피드백 제공 - 사용자의 OS 애니메이션 줄이기 설정(prefers-reduced-motion)을 지원하는 미디어 쿼리 추가 - 키보드 내비게이션 사용자를 위한 `main` 태그의 focus outline 처리 및 skip-link의 `focus-visible` 개선 --- .Jules/palette.md | 3 ++ .../fast_mlsirm/scoring/essay/report_html.py | 41 ++++++++++++------- tests/test_scoring_essay_report_html.py | 2 +- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.Jules/palette.md b/.Jules/palette.md index 09a299bfc..70087cfde 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -20,3 +20,6 @@ ## 2026-08-04 - Focus Visible For Scrollable Code Blocks **Learning:** Code blocks (`
`) that contain wide text (like JSON or CSV exports) require horizontal scrolling, but by default they cannot receive keyboard focus, locking keyboard-only users out of viewing the full content. **Action:** Always add `tabindex="0"`, `role="region"`, `aria-label`, and a `:focus-visible` outline to scrollable `` or code containers to ensure full keyboard navigability and clear visual focus feedback. +## 2026-08-05 - ARIA Live Regions and Tabular Nums for Diagnostics +**Learning:** Empty states in dynamically generated reports using basic `` tags are often skipped by screen readers. Furthermore, displaying financial or statistical data without aligned decimals can cause extreme eye strain and layout shifting for the users. +**Action:** Used `
` for any dynamic empty message sections so that screen readers correctly announce the lack of data to users without vision. Used `font-variant-numeric: tabular-nums;` in all report table cells to naturally align metric statistics. diff --git a/python/fast_mlsirm/scoring/essay/report_html.py b/python/fast_mlsirm/scoring/essay/report_html.py index 469ed247d..7051750c7 100644 --- a/python/fast_mlsirm/scoring/essay/report_html.py +++ b/python/fast_mlsirm/scoring/essay/report_html.py @@ -72,7 +72,7 @@ def _definition_rows(rows: tuple[tuple[str, object], ...]) -> str: items = [] for label, value in rows: items.extend((f"{escape(label)} ", f"{_display(value)} ")) - return "\n".join(("", *items, "
")) + return "\n".join(('', *items, "
")) def _table( @@ -84,7 +84,7 @@ def _table( ) -> str: """Render an accessible exact-value table or one explicit empty state.""" if not rows: - return f'{escape(empty_message)}
' + return f'{escape(empty_message)}' heading = "".join(f'{escape(header)} ' for header in headers) body = [] for row in rows: @@ -138,7 +138,7 @@ def _evidence_rows(report: EssayScoreReport) -> tuple[tuple[object | None, ...], def _trigger_section(report: EssayScoreReport) -> str: """Render every transparent review trigger or an explicit empty state.""" if not report.review_trigger_ids: - return 'No structural review trigger was emitted.
' + return 'No structural review trigger was emitted.' items = "".join( f"" for trigger_id in report.review_trigger_ids @@ -165,8 +165,10 @@ def _css() -> str: * { box-sizing: border-box; } body { margin: 0; background: Canvas; color: CanvasText; } main { width: min(1120px, calc(100% - 32px)); margin: 0 auto 48px; } -.skip-link { position: absolute; left: 8px; top: -80px; padding: 10px; background: Canvas; color: CanvasText; z-index: 10; } -.skip-link:focus { top: 8px; } +main:focus { outline: none; } +main:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; } +.skip-link { position: absolute; left: 8px; top: -80px; padding: 10px; background: Canvas; color: CanvasText; z-index: 10; transition: top 0.2s ease-in-out; text-decoration: none; font-weight: bold; } +.skip-link:focus-visible { top: 8px; outline: 3px solid Highlight; outline-offset: 2px; } .hero { padding: 48px 0 24px; } h1 { margin: 0 0 8px; font-size: clamp(2rem, 5vw, 3.2rem); } .subtitle { margin: 0; max-width: 78ch; } @@ -181,11 +183,22 @@ def _css() -> str: .table-scroll:focus-visible, pre:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; } table { width: 100%; border-collapse: collapse; } caption { text-align: left; font-weight: 700; margin-bottom: 8px; } -th, td { padding: 10px; border: 1px solid GrayText; text-align: left; vertical-align: top; overflow-wrap: anywhere; } +th, td { padding: 10px; border: 1px solid GrayText; text-align: left; vertical-align: top; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +tbody tr { transition: background-color 0.15s ease-in-out, opacity 0.2s ease; } +tbody tr:hover { background-color: rgba(128, 128, 128, 0.15); } +tbody:hover tr:not(:hover) { opacity: 0.5; } code, pre { font-family: ui-monospace, monospace; } pre { max-height: 32rem; overflow: auto; padding: 16px; border: 1px solid GrayText; white-space: pre-wrap; overflow-wrap: anywhere; } .empty-state { font-style: italic; } @media (max-width: 640px) { .details-grid { grid-template-columns: 1fr; } .details-grid dd { margin-bottom: 8px; } } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} """.strip() @@ -194,7 +207,11 @@ def _render_html(report: EssayScoreReport, title: str) -> str: engine = report.engine_descriptor request = report.essay_request.scoring_request review_class = "review-required" if report.human_review_required else "review-clear" - review_label = "Human review required" if report.human_review_required else "No structural trigger" + review_label = ( + "Human review required" + if report.human_review_required + else "No structural trigger" + ) provenance = _definition_rows( ( ("Report ID", report.report_id), @@ -277,7 +294,7 @@ def _render_html(report: EssayScoreReport, title: str) -> str: "", ' {escape(trigger_id)}', ' Canonical JSON
', - 'The complete deterministic report payload is available below for audit reconstruction.
', + "The complete deterministic report payload is available below for audit reconstruction.
", '', _canonical_json(report), "", @@ -305,12 +322,8 @@ def render_essay_score_report_html( output = Path(output_path) if output.suffix.lower() != ".html": raise ValueError("essay score report output path must end with .html") - if title is not None and ( - not isinstance(title, str) or not title.strip() - ): - raise ValueError( - "essay score report title must be a non-empty string" - ) + if title is not None and (not isinstance(title, str) or not title.strip()): + raise ValueError("essay score report title must be a non-empty string") resolved_title = _DEFAULT_TITLE if title is None else title output.parent.mkdir(parents=True, exist_ok=True) output.write_text(_render_html(validated, resolved_title), encoding="utf-8") diff --git a/tests/test_scoring_essay_report_html.py b/tests/test_scoring_essay_report_html.py index 568dbb5ae..4684f347e 100644 --- a/tests/test_scoring_essay_report_html.py +++ b/tests/test_scoring_essay_report_html.py @@ -139,7 +139,7 @@ def test_empty_table_renders_an_explicit_empty_state() -> None: rows=(), empty_message="No evidence is available.", ) - assert rendered == 'No evidence is available.
' + assert rendered == 'No evidence is available.' def test_renderer_rejects_wrong_type_and_wrong_suffix(tmp_path: Path) -> None: From 27e52f8eecb52e59156f80c658df543311d06210 Mon Sep 17 00:00:00 2001 From: Seongho BaeDate: Wed, 5 Aug 2026 04:04:11 +0900 Subject: [PATCH 02/10] fix: preserve focus visibility and contrast in essay report --- python/fast_mlsirm/scoring/essay/report_html.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/python/fast_mlsirm/scoring/essay/report_html.py b/python/fast_mlsirm/scoring/essay/report_html.py index 7051750c7..10a73f205 100644 --- a/python/fast_mlsirm/scoring/essay/report_html.py +++ b/python/fast_mlsirm/scoring/essay/report_html.py @@ -165,10 +165,10 @@ def _css() -> str: * { box-sizing: border-box; } body { margin: 0; background: Canvas; color: CanvasText; } main { width: min(1120px, calc(100% - 32px)); margin: 0 auto 48px; } -main:focus { outline: none; } main:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; } .skip-link { position: absolute; left: 8px; top: -80px; padding: 10px; background: Canvas; color: CanvasText; z-index: 10; transition: top 0.2s ease-in-out; text-decoration: none; font-weight: bold; } -.skip-link:focus-visible { top: 8px; outline: 3px solid Highlight; outline-offset: 2px; } +.skip-link:focus { top: 8px; } +.skip-link:focus-visible { outline: 3px solid Highlight; outline-offset: 2px; } .hero { padding: 48px 0 24px; } h1 { margin: 0 0 8px; font-size: clamp(2rem, 5vw, 3.2rem); } .subtitle { margin: 0; max-width: 78ch; } @@ -184,9 +184,8 @@ def _css() -> str: table { width: 100%; border-collapse: collapse; } caption { text-align: left; font-weight: 700; margin-bottom: 8px; } th, td { padding: 10px; border: 1px solid GrayText; text-align: left; vertical-align: top; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } -tbody tr { transition: background-color 0.15s ease-in-out, opacity 0.2s ease; } +tbody tr { transition: background-color 0.15s ease-in-out; } tbody tr:hover { background-color: rgba(128, 128, 128, 0.15); } -tbody:hover tr:not(:hover) { opacity: 0.5; } code, pre { font-family: ui-monospace, monospace; } pre { max-height: 32rem; overflow: auto; padding: 16px; border: 1px solid GrayText; white-space: pre-wrap; overflow-wrap: anywhere; } .empty-state { font-style: italic; } From 0be8877a1a97b39f00c27a30dd7fdccb659a45b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 04:04:37 +0900 Subject: [PATCH 03/10] test: lock accessible essay report focus and contrast behavior --- tests/test_scoring_essay_report_html.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_scoring_essay_report_html.py b/tests/test_scoring_essay_report_html.py index 4684f347e..31d67996c 100644 --- a/tests/test_scoring_essay_report_html.py +++ b/tests/test_scoring_essay_report_html.py @@ -83,6 +83,7 @@ def test_clean_report_renders_deterministic_accessible_exact_values( assert "default-src 'none'" in first assert "script-src" not in first assert "No structural review trigger was emitted." in first + assert 'class="empty-state" role="status"' in first assert "No structural trigger" in first assert report.report_fingerprint in first assert report.engine_descriptor.engine_fingerprint in first @@ -93,6 +94,12 @@ def test_clean_report_renders_deterministic_accessible_exact_values( assert "Not applicable" in first assert "Absence of a trigger is not evidence" in first assert ""report_fingerprint"" in first + assert ".skip-link:focus { top: 8px; }" in first + assert "main:focus-visible" in first + assert "main:focus { outline: none; }" not in first + assert "font-variant-numeric: tabular-nums;" in first + assert "@media (prefers-reduced-motion: reduce)" in first + assert "tbody:hover tr:not(:hover)" not in first assert "