diff --git a/CHANGELOG.md b/CHANGELOG.md
index f76549a..59e9f96 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ All notable changes to Fair Code are documented here, newest first.
- **Change-aware CI: `profiler`/`benchmark-harness` skip on docs-only changes** - extends the same idea to `.github/workflows/audits.yml`. A new `changes` job diffs the push/PR against its base commit and gates `profiler` (the full pytest suite) and `benchmark-harness` (the fairlearn end-to-end smoke test) behind whether anything test-relevant changed - same path set as the pre-push hook above, kept in sync deliberately. `run-audits` is left untouched and always runs: it's the one job in this workflow that's a required status check (#160), and a job skipped via `if:` can leave a required check permanently "waiting to be reported" instead of green, which would block merging every docs-only PR. `profiler`/`benchmark-harness` aren't required checks, so skipping them is risk-free.
- **Theme-toggle button now exposes its state to assistive tech** (closes #250) - `#themeToggle`/`#explainerThemeToggle` had a dynamic `aria-label` ("Switch to light/dark mode") but nothing a screen reader announces as state, unlike the visible ☀/☾ glyph swap. Added `aria-pressed="true"/"false"` (`true` = dark mode active), kept in sync everywhere the label/glyph already update - initial load and on click - across `index.html`, `profiler.html`, and the explainer-page template in `scripts/build_explainers.py` (`make build-explainers` re-run to regenerate all 39 explainer pages).
- **`scripts/check_broken_links.py`** (closes #253) - checks every tracked `.md` file for a `[text](#anchor)` that doesn't match any heading on that page, or a relative link/anchor to another file that doesn't exist. Wired into `make lint`/`lint.yml` alongside the em-dash check (that job is renamed `lint` accordingly). The anchor slugifier was reverse-engineered against all 71 real anchor links already in the repo (70 matched cleanly; the one holdout is a `#link-to-section` placeholder inside a CONTRIBUTING.md code-fence example, correctly ignored) rather than assumed, since GitHub's exact algorithm isn't public. Running it against the current tree found five real, previously-unnoticed broken links in the raw markdown - three explainers linked a misspelled/nonexistent `Ai Fair Recrutment Dataset` folder instead of `AI Fair Recruitment`, and `proxy-entanglement.md` linked a notebook path missing its `../` prefix - fixed in `explainers/neural-networks.md`, `proxy-variables.md`, `shap-values.md`, and `proxy-entanglement.md`.
+- **`faircode/report.py`'s HTML report tables get proper headers** (closes #254) - the per-column breakdown table and the drift-comparison table had no `
` header row at all (only the smaller reference-deviation table did, and even that had no `scope`); a screen reader had nothing to announce when reading a data cell. Added a ``/`` row plus a `` to all three tables (in both `to_html` and `compare_to_html`), with matching `th`/`caption` CSS in each report's embedded stylesheet - the caption text stands on its own since the report is often shared outside the page it was generated on (CI output, email, a PR comment).
### Fixed
- **`scripts/build_explainers.py`'s `resolve_link_target()` silently broke every generic cross-repo-root link in an explainer** - found while fixing #253 above: rebuilding after the notebook-link fix showed the generated `.html` still pointing at the old broken path. Its fallback branch stripped a leading `../` from any relative link that wasn't a known explainer `.md` or a recognized project folder, even though `explainers/*.md` and the `.html` generated from it live in the same directory, so a plain relative link needs no rewriting at all. This wasn't just the notebook link - `disparate-impact.md`'s links to `unfair.py`/`fair.py` in `AI Fair Recruitment/` had the exact same bug, already live on the deployed site. Also removed a `PROJECT_ANCHORS` entry that hardcoded the misspelled `"Ai Fair Recrutment Dataset"` folder name as a redirect target - a band-aid for the exact typo fixed at the source above, now dead code.
diff --git a/faircode/report.py b/faircode/report.py
index 5dae654..0001326 100644
--- a/faircode/report.py
+++ b/faircode/report.py
@@ -191,8 +191,9 @@ def esc(s) -> str:
reference_html = (
f'Reference '
f'deviation {ref["deviation"] * 100:.1f}%'
- f' | Expected | '
- f'Actual | Delta | '
+ f'Expected vs. actual share - {esc(d["name"])}'
+ f' | Expected | '
+ f'Actual | Delta | '
f'{ref_rows} '
)
@@ -200,7 +201,11 @@ def esc(s) -> str:
f'{esc(d["name"])} '
f'{esc(d["kind"])} '
f'{d["dimension_score"]}/100'
- f'{reference_html}'
+ f'Group breakdown - {esc(d["name"])}'
+ f'| Group | Share | '
+ f'95% CI | Count | '
+ f' | '
+ f'{"".join(rows)} {reference_html}'
)
flag_html = ""
@@ -236,6 +241,11 @@ def esc(s) -> str:
.dim {{ background:var(--surface); border:1px solid var(--border); border-radius:8px;
padding:16px 20px; margin:16px 0; }}
table {{ width:100%; border-collapse:collapse; }}
+ caption {{ text-align:left; font-size:11px; color:var(--muted); text-transform:uppercase;
+ letter-spacing:.04em; margin-bottom:4px; }}
+ th {{ padding:4px 8px; font-size:14px; font-weight:600; text-align:left;
+ border-bottom:2px solid var(--border); }}
+ th.num {{ text-align:right; }}
td {{ padding:4px 8px; font-size:14px; border-bottom:1px solid var(--border); }}
td.num {{ text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }}
td.ci {{ color:var(--muted); font-size:12px; }}
@@ -348,7 +358,10 @@ def signed(val: float | int, dp: int = 1) -> str:
f'{esc(cd["drift_level"])} drift'
f'PSI {cd["psi"]:.3f} · TVD {cd["tvd"]:.3f} · score {cd["dimension_score_a"]}→{cd["dimension_score_b"]} ({signed(cd["dimension_score_delta"], 0)}) '
''
- f''
+ f'Group-level share drift - {esc(cd["name"])}'
+ f'| Group | Share A → B | '
+ f'Δ | | '
+ f'{"".join(rows)} '
f'{more_html}'
''
)
@@ -390,6 +403,11 @@ def signed(val: float | int, dp: int = 1) -> str:
".drift-card-head h2 { margin:0; font-size:18px; } "
".drift-metrics { font-size:12px; color:var(--muted); } "
"table { width:100%; border-collapse:collapse; } "
+ "caption { text-align:left; font-size:11px; color:var(--muted); text-transform:uppercase; "
+ "letter-spacing:.04em; margin-bottom:4px; } "
+ "th { padding:6px 8px; font-size:14px; font-weight:600; text-align:left; "
+ "border-bottom:2px solid var(--border); } "
+ "th.num { text-align:right; } "
"td { padding:6px 8px; font-size:14px; border-bottom:1px solid var(--border); } "
"td.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; font-size:13px; } "
"td.label { width:25%; } "
| |