Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@
## 2026-08-04 - Focus Visible For Scrollable Code Blocks
**Learning:** Code blocks (`<pre>`) 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 `<pre>` or code containers to ensure full keyboard navigability and clear visual focus feedback.
## 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.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
seonghobae marked this conversation as resolved.
- 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
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.d/essay-score-report-html.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
38 changes: 25 additions & 13 deletions python/fast_mlsirm/scoring/essay/report_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def _definition_rows(rows: tuple[tuple[str, object], ...]) -> str:
items = []
for label, value in rows:
items.extend((f"<dt>{escape(label)}</dt>", f"<dd>{_display(value)}</dd>"))
return "\n".join(("<dl class=\"details-grid\">", *items, "</dl>"))
return "\n".join(('<dl class="details-grid">', *items, "</dl>"))


def _table(
Expand All @@ -84,7 +84,7 @@ def _table(
) -> str:
"""Render an accessible exact-value table or one explicit empty state."""
if not rows:
return f'<p class="empty-state">{escape(empty_message)}</p>'
return f'<div class="empty-state" role="status">{escape(empty_message)}</div>'
heading = "".join(f'<th scope="col">{escape(header)}</th>' for header in headers)
body = []
for row in rows:
Expand Down Expand Up @@ -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 '<p class="empty-state">No structural review trigger was emitted.</p>'
return '<div class="empty-state" role="status">No structural review trigger was emitted.</div>'
items = "".join(
f"<li><code>{escape(trigger_id)}</code></li>"
for trigger_id in report.review_trigger_ids
Expand All @@ -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; }
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 { 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; }
Expand All @@ -181,11 +183,21 @@ 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; }
tbody tr:hover { background-color: rgba(128, 128, 128, 0.15); }
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()


Expand All @@ -194,7 +206,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),
Expand Down Expand Up @@ -277,7 +293,7 @@ def _render_html(report: EssayScoreReport, title: str) -> str:
"</section>",
'<section aria-labelledby="json-heading">',
'<h2 id="json-heading">Canonical JSON</h2>',
'<p>The complete deterministic report payload is available below for audit reconstruction.</p>',
"<p>The complete deterministic report payload is available below for audit reconstruction.</p>",
'<pre tabindex="0" role="region" aria-label="Canonical essay score report JSON">',
_canonical_json(report),
"</pre>",
Expand Down Expand Up @@ -305,12 +321,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")
Expand Down
14 changes: 13 additions & 1 deletion tests/test_scoring_essay_report_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ def test_clean_report_renders_deterministic_accessible_exact_values(
assert "default-src &#x27;none&#x27;" 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
Expand All @@ -93,6 +94,17 @@ 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 "&quot;report_fingerprint&quot;" 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
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
Comment thread
seonghobae marked this conversation as resolved.
assert "<script" not in first.lower()


Expand Down Expand Up @@ -139,7 +151,7 @@ def test_empty_table_renders_an_explicit_empty_state() -> None:
rows=(),
empty_message="No evidence is available.",
)
assert rendered == '<p class="empty-state">No evidence is available.</p>'
assert rendered == '<div class="empty-state" role="status">No evidence is available.</div>'


def test_renderer_rejects_wrong_type_and_wrong_suffix(tmp_path: Path) -> None:
Expand Down
Loading