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"
  • {escape(trigger_id)}
  • " 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: "", '
    ', '

    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 Bae Date: 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 " Date: Wed, 5 Aug 2026 04:04:47 +0900 Subject: [PATCH 04/10] docs: record essay report accessibility refinements --- docs/changelog.d/essay-score-report-html.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.d/essay-score-report-html.md b/docs/changelog.d/essay-score-report-html.md index 571f3f7bb..0e3280b5c 100644 --- a/docs/changelog.d/essay-score-report-html.md +++ b/docs/changelog.d/essay-score-report-html.md @@ -4,4 +4,5 @@ - Added `render_essay_score_report_html`, which replay-verifies one governed `EssayScoreReport` and emits a deterministic, source-text-free, script-free standalone HTML audit artifact. - The artifact exposes exact report, assessment, rubric, task-revision, engine, request, result, observation, criterion, trigger, and evidence-reference identities through semantic landmarks, keyboard-accessible exact-value tables, and canonical JSON. +- Empty states use explicit status semantics, numeric table cells use tabular numerals, skip links remain visible for any received focus, and motion-sensitive users receive a reduced-motion override without dimming non-hovered report rows. - A restrictive meta-delivered Content Security Policy and output encoding reduce content-injection impact. Review routing remains an audit signal only and does not establish scoring validity, fairness, reliability, interchangeability, accessibility conformance, security certification, or authorization for consequential deployment. From 44d96bd6b16d9595d826f58f2bdc17aeafb44bd3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 04:05:02 +0900 Subject: [PATCH 05/10] docs: make accessibility palette guidance evidence-conservative --- .Jules/palette.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.Jules/palette.md b/.Jules/palette.md index 70087cfde..45f933247 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -20,6 +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. +## 2026-08-05 - Status Semantics and Numeric Alignment for Reports +**Learning:** Explicit status semantics can make conditionally rendered empty states easier to discover with assistive technology, while tabular numerals improve visual comparison of metric columns. Focus-reveal behavior must not depend only on `:focus-visible`, and hover styling must not reduce the contrast of unrelated rows. +**Action:** Use `role="status"` for genuine conditionally rendered status messages, apply `font-variant-numeric: tabular-nums` to numeric report tables, reveal skip links on `:focus`, retain a visible `:focus-visible` indicator, and avoid opacity-based dimming of non-hovered content. From 6d73b45dd9e763f6f6dad0383a0b10f01dbfc879 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 04:11:49 +0900 Subject: [PATCH 06/10] chore(ci): finalize report accessibility changelog --- .../one-time-palette-changelog-finalize.yml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/one-time-palette-changelog-finalize.yml diff --git a/.github/workflows/one-time-palette-changelog-finalize.yml b/.github/workflows/one-time-palette-changelog-finalize.yml new file mode 100644 index 000000000..8aefc87ae --- /dev/null +++ b/.github/workflows/one-time-palette-changelog-finalize.yml @@ -0,0 +1,55 @@ +# Temporary workflow; removes itself after rendering and validation. +name: One-time palette changelog finalize + +on: + push: + branches: + - palette/html-report-ux-a11y-13005393850638333940 + paths: + - .github/workflows/one-time-palette-changelog-finalize.yml + +permissions: {} + +concurrency: + group: one-time-palette-changelog-finalize-${{ github.ref }} + cancel-in-progress: true + +jobs: + finalize: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + steps: + - name: Check out exact feature branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: palette/html-report-ux-a11y-13005393850638333940 + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.12' + - name: Render authoritative changelog + run: | + python scripts/render_changelog_fragments.py --update CHANGELOG.md + python scripts/render_changelog_fragments.py --check CHANGELOG.md + - name: Install package and CI dependencies + run: | + python -m pip install --require-hashes -r requirements/ci.txt + python -m pip install --no-deps --no-build-isolation -e . + - name: Run focused validation + run: | + pytest \ + tests/test_scoring_essay_report_html.py \ + tests/test_changelog_fragment_contract.py + - name: Commit rendered changelog and remove workflow + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm .github/workflows/one-time-palette-changelog-finalize.yml + git add CHANGELOG.md + git diff --cached --check + git commit -m "docs(changelog): render report accessibility notes" + git push origin "HEAD:palette/html-report-ux-a11y-13005393850638333940" From 079afa7a69a65af0bfcbfff016db08f5050d39eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:13:40 +0000 Subject: [PATCH 07/10] docs(changelog): render report accessibility notes --- .../one-time-palette-changelog-finalize.yml | 55 ------------------- CHANGELOG.md | 1 + 2 files changed, 1 insertion(+), 55 deletions(-) delete mode 100644 .github/workflows/one-time-palette-changelog-finalize.yml diff --git a/.github/workflows/one-time-palette-changelog-finalize.yml b/.github/workflows/one-time-palette-changelog-finalize.yml deleted file mode 100644 index 8aefc87ae..000000000 --- a/.github/workflows/one-time-palette-changelog-finalize.yml +++ /dev/null @@ -1,55 +0,0 @@ -# Temporary workflow; removes itself after rendering and validation. -name: One-time palette changelog finalize - -on: - push: - branches: - - palette/html-report-ux-a11y-13005393850638333940 - paths: - - .github/workflows/one-time-palette-changelog-finalize.yml - -permissions: {} - -concurrency: - group: one-time-palette-changelog-finalize-${{ github.ref }} - cancel-in-progress: true - -jobs: - finalize: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: write - steps: - - name: Check out exact feature branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: palette/html-report-ux-a11y-13005393850638333940 - fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: '3.12' - - name: Render authoritative changelog - run: | - python scripts/render_changelog_fragments.py --update CHANGELOG.md - python scripts/render_changelog_fragments.py --check CHANGELOG.md - - name: Install package and CI dependencies - run: | - python -m pip install --require-hashes -r requirements/ci.txt - python -m pip install --no-deps --no-build-isolation -e . - - name: Run focused validation - run: | - pytest \ - tests/test_scoring_essay_report_html.py \ - tests/test_changelog_fragment_contract.py - - name: Commit rendered changelog and remove workflow - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm .github/workflows/one-time-palette-changelog-finalize.yml - git add CHANGELOG.md - git diff --cached --check - git commit -m "docs(changelog): render report accessibility notes" - git push origin "HEAD:palette/html-report-ux-a11y-13005393850638333940" diff --git a/CHANGELOG.md b/CHANGELOG.md index 40bd9bbdd..0d9b52d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Added `render_essay_score_report_html`, which replay-verifies one governed `EssayScoreReport` and emits a deterministic, source-text-free, script-free standalone HTML audit artifact. - The artifact exposes exact report, assessment, rubric, task-revision, engine, request, result, observation, criterion, trigger, and evidence-reference identities through semantic landmarks, keyboard-accessible exact-value tables, and canonical JSON. +- Empty states use explicit status semantics, numeric table cells use tabular numerals, skip links remain visible for any received focus, and motion-sensitive users receive a reduced-motion override without dimming non-hovered report rows. - A restrictive meta-delivered Content Security Policy and output encoding reduce content-injection impact. Review routing remains an audit signal only and does not establish scoring validity, fairness, reliability, interchangeability, accessibility conformance, security certification, or authorization for consequential deployment. #### Provenance-bound essay score reports From 5728f3be6ed6162c1af90c19660eaacfa1d975bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 04:16:04 +0900 Subject: [PATCH 08/10] test: assert reduced-motion transition override --- tests/test_scoring_essay_report_html.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_scoring_essay_report_html.py b/tests/test_scoring_essay_report_html.py index 31d67996c..f3bdceb32 100644 --- a/tests/test_scoring_essay_report_html.py +++ b/tests/test_scoring_essay_report_html.py @@ -99,6 +99,7 @@ def test_clean_report_renders_deterministic_accessible_exact_values( 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 "transition-duration: 0.01ms !important;" in first assert "tbody:hover tr:not(:hover)" not in first assert " Date: Wed, 5 Aug 2026 04:21:43 +0900 Subject: [PATCH 09/10] docs: align accessibility note date with repository chronology --- .Jules/palette.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.Jules/palette.md b/.Jules/palette.md index 45f933247..d8f0c25d5 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -20,6 +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 - Status Semantics and Numeric Alignment for Reports
    +## 2026-08-04 - Status Semantics and Numeric Alignment for Reports
     **Learning:** Explicit status semantics can make conditionally rendered empty states easier to discover with assistive technology, while tabular numerals improve visual comparison of metric columns. Focus-reveal behavior must not depend only on `:focus-visible`, and hover styling must not reduce the contrast of unrelated rows.
     **Action:** Use `role="status"` for genuine conditionally rendered status messages, apply `font-variant-numeric: tabular-nums` to numeric report tables, reveal skip links on `:focus`, retain a visible `:focus-visible` indicator, and avoid opacity-based dimming of non-hovered content.
    
    From 28099aa0a2a6482731705ec84baa0c9048deae9c Mon Sep 17 00:00:00 2001
    From: Seongho Bae 
    Date: Wed, 5 Aug 2026 04:22:09 +0900
    Subject: [PATCH 10/10] test: verify skip-link focus-visible outline contract
    
    ---
     tests/test_scoring_essay_report_html.py | 4 ++++
     1 file changed, 4 insertions(+)
    
    diff --git a/tests/test_scoring_essay_report_html.py b/tests/test_scoring_essay_report_html.py
    index f3bdceb32..acdf3eef5 100644
    --- a/tests/test_scoring_essay_report_html.py
    +++ b/tests/test_scoring_essay_report_html.py
    @@ -95,6 +95,10 @@ def test_clean_report_renders_deterministic_accessible_exact_values(
         assert "Absence of a trigger is not evidence" in first
         assert ""report_fingerprint"" in first
         assert ".skip-link:focus { top: 8px; }" in first
    +    assert (
    +        ".skip-link:focus-visible { outline: 3px solid Highlight; "
    +        "outline-offset: 2px; }"
    +    ) in first
         assert "main:focus-visible" in first
         assert "main:focus { outline: none; }" not in first
         assert "font-variant-numeric: tabular-nums;" in first