Flamehaven Initiative commited on
Commit
e3afeec
·
1 Parent(s): 96b172f

feat: refresh html report explainer surface

Browse files
Files changed (2) hide show
  1. stem_ai/render_html.py +380 -162
  2. stem_ai/render_html_styles.py +222 -93
stem_ai/render_html.py CHANGED
@@ -1,6 +1,6 @@
1
- """5-section interactive HTML report renderer for STEM BIO-AI.
2
 
3
- Sections: Executive Summary | Score Matrix | Code Integrity | AIRI Coverage | Evidence Detail
4
  Self-contained: inline CSS + SVG + JS, zero external dependencies.
5
  """
6
  from __future__ import annotations
@@ -8,10 +8,18 @@ from __future__ import annotations
8
  from typing import Any
9
 
10
  from .render_html_components import (
11
- _C, _STAGE_TIPS,
12
- xt, tier_color, tip_icon,
13
- svg_gauge, svg_donut, svg_hbar,
14
- integrity_card, domain_card, airi_row, evidence_row,
 
 
 
 
 
 
 
 
15
  )
16
  from .render_html_styles import build_css, JS
17
 
@@ -29,8 +37,10 @@ def _calibration_effect_note(calibration: dict[str, Any]) -> str | None:
29
 
30
  def _nav() -> str:
31
  links = [
32
- ("#s1", "1. Summary"), ("#s2", "2. Score Matrix"),
33
- ("#s3", "3. Code Integrity"), ("#s4", "4. AIRI Coverage"),
 
 
34
  ("#s5", "5. Evidence"),
35
  ]
36
  items = "".join(f'<a href="{h}" class="nav-link">{l}</a>' for h, l in links)
@@ -42,30 +52,108 @@ def _hero(score: dict, final: int, tier: str, tc: str, target: str, date: str) -
42
  use_scope = xt(str(score.get("use_scope", "")))
43
  return (
44
  f'<header class="hero">'
45
- f'<div>{gauge}</div>'
46
- f'<div>'
47
- f'<div class="sub">STEM BIO-AI Local Audit &nbsp;|&nbsp; {date}</div>'
48
  f'<h1>{target}</h1>'
49
- f'<div class="tier">{xt(tier)}</div>'
50
- f'<p style="font-size:13px;color:rgba(255,255,255,.75);'
51
- f'max-width:520px;line-height:1.5;margin-top:6px">{use_scope}</p>'
 
 
 
52
  f'</div></header>'
53
  )
54
 
55
 
56
- def _section1(result: dict, final: int, tc: str,
57
- s1: int, s2: int, s3: int, s4: int, t0: bool) -> str:
58
- alert = ""
59
- if t0:
60
- alert = (
61
- f'<div class="alert-t0">'
62
- f'T0 HARD FLOOR TRIGGERED &mdash; Clinical boundary declaration absent. '
63
- f'Score capped at {final}/100.</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  stats = "".join(
66
- f'<div class="stat-card">'
67
- f'<div class="stat-val" style="color:{c}">{v}</div>'
68
- f'<div class="stat-lbl">{l}</div></div>'
 
69
  for v, l, c in [
70
  (final, "Final Score", tc),
71
  (s1, "S1 Intent", _C["teal"]),
@@ -74,90 +162,165 @@ def _section1(result: dict, final: int, tc: str,
74
  (s4, "S4 Replication", _C["green"]),
75
  ]
76
  )
77
- notable = "".join(
78
- f'<div class="risk-item">{xt(str(r))}</div>'
79
- for r in result.get("notable_risks", [])[:6]
80
- )
81
- notable_block = (
82
- f'<div style="margin-top:24px">'
83
- f'<h3 style="font-size:15px;font-weight:700;color:{_C["navy"]};margin-bottom:12px">'
84
- f'Notable Risks</h3>{notable}</div>'
85
- if notable else ""
86
- )
87
- calibration = result.get("calibration_profile", {})
88
- freshness = result.get("audit_freshness", {})
89
- policy_name = xt(str(calibration.get("profile_name", "unknown")))
90
- policy_status = xt(str(calibration.get("profile_status", "unknown")))
91
- policy_mode = xt(str(calibration.get("profile_read_mode", "unknown")))
92
- calibration_note = _calibration_effect_note(calibration)
93
- policy_block = (
94
- f'<div class="panel" style="margin-top:20px;padding:14px 16px">'
95
- f'<div style="font-size:12px;font-weight:700;color:{_C["navy"]};margin-bottom:6px">'
96
- f'Policy Surface: {policy_name} ({policy_status}, {policy_mode})</div>'
97
- f'<div style="font-size:12px;line-height:1.55;color:{_C["dgray"]}">'
98
- f'{xt(calibration_note)}</div></div>'
99
- if calibration_note
100
- else ""
101
- )
102
- freshness_block = (
103
- f'<div class="panel" style="margin-top:14px;padding:14px 16px">'
104
- f'<div style="font-size:12px;font-weight:700;color:{_C["navy"]};margin-bottom:6px">'
105
- f'Audit Freshness</div>'
106
- f'<div style="font-size:12px;line-height:1.55;color:{_C["dgray"]}">'
107
- f'Review after: {xt(str(freshness.get("review_after_days", "n/a")))} days | '
108
- f'Expires on: {xt(str(freshness.get("expires_on", "unknown")))}<br>'
109
- f'Change-triggered re-audit now: '
110
- f'{xt(str(freshness.get("change_triggered_reaudit_recommended_now", False)))}'
111
- f'</div></div>'
112
- if freshness
113
- else ""
114
  )
 
 
115
  return (
116
  f'<section id="s1">'
117
  f'<h2 class="s-title">Executive Summary</h2>'
118
  f'{alert}'
119
- f'<div class="stats-grid">{stats}</div>'
120
- f'{policy_block}'
121
- f'{freshness_block}'
122
- f'{notable_block}'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  f'</section>'
124
  )
125
 
126
 
127
- def _section2(s1: int, s2: int, s3: int, s4: int) -> str:
128
- stages = [
129
- ("Stage 1 &mdash; README Intent", s1, _C["teal"], 0),
130
- ("Stage 2R &mdash; Repo Consistency", s2, _C["purple"], 1),
131
- ("Stage 3 &mdash; Code / Bio", s3, _C["slate"], 2),
132
- ("Stage 4 &mdash; Replication", s4, _C["green"], 3),
133
- ]
134
- rows = ""
135
- for label, val, color, ti in stages:
136
- bar_color = _C["red"] if val < 40 else (_C["amber"] if val < 65 else color)
137
- rows += (
138
- f'<div class="stage-row">'
139
- f'<div style="display:flex;justify-content:space-between;'
140
- f'align-items:center;margin-bottom:6px">'
141
- f'<span style="font-size:13px;color:{_C["navy"]};font-weight:500;'
142
- f'display:flex;align-items:center;gap:6px">'
143
- f'{label}{tip_icon(_STAGE_TIPS[ti])}</span>'
144
- f'<span style="font-size:14px;font-weight:700;color:{bar_color}">'
145
- f'{val}</span></div>'
146
- f'{svg_hbar(val, 100, bar_color)}</div>'
147
- )
148
- score_tip = tip_icon(
149
- "Weighted formula: 40% README intent + 20% repo consistency + 40% code/bio. "
150
- "T0 hard floor (missing clinical disclaimer) caps score at 39/100 maximum."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  return (
153
  f'<section id="s2">'
154
- f'<h2 class="s-title">Score Matrix {score_tip}</h2>'
155
- f'<div class="panel">'
156
- f'{rows}'
157
- f'<div class="formula">'
158
- f'Final = 0.4 &times; S1 + 0.2 &times; S2R + 0.4 &times; S3 &minus; C1_penalty'
159
- f' &nbsp;|&nbsp; T0 floor: max 39/100'
160
- f'</div></div></section>'
161
  )
162
 
163
 
@@ -169,30 +332,50 @@ def _section3(integrity: dict, cc: dict) -> str:
169
  "status": v.get("status", "PASS"),
170
  "evidence": [f"count={v.get('count', 0)}"],
171
  }
172
- cards = "".join(integrity_card(k, v) for k, v in combined.items() if isinstance(v, dict))
 
 
173
  hint = tip_icon(
174
- "C1-C6: static code and governance checks (credentials, dependencies, deprecated paths, exceptions, compliance boundaries, mock-auth/fail-open local trust boundaries). "
175
- "CC1-CC3: Layer 2 AST contract detectors. Click any card to expand evidence."
 
 
 
 
 
 
 
 
176
  )
177
  return (
178
  f'<section id="s3">'
179
  f'<h2 class="s-title">Code Integrity &amp; Contract {hint}</h2>'
180
- f'<div class="card-grid">{cards}</div>'
181
- f'</section>'
 
 
 
 
 
 
 
 
 
 
 
182
  )
183
 
184
 
185
  def _section4(airi: dict) -> str:
186
  if not airi or "covered_risks" not in airi:
187
  return ""
188
- pct = float(airi.get("coverage_rate", 0))
189
  covered_n = int(airi.get("covered_count", 0))
190
- total_n = int(airi.get("total_risks_in_detector_scope", 0))
191
- gaps = airi.get("known_gaps", [])
192
  all_risks = airi.get("covered_risks", [])
193
- donut = svg_donut(pct, _C["green"], 90)
194
 
195
- # Count covered risks per AIRI domain (first digit of subdomain_id)
196
  domain_counts: dict[int, int] = {d: 0 for d in range(1, 8)}
197
  for r in all_risks:
198
  try:
@@ -206,55 +389,88 @@ def _section4(airi: dict) -> str:
206
  c_rows = "".join(airi_row(r, "covered") for r in all_risks[:24])
207
  g_rows = "".join(airi_row(g, "gap") for g in gaps)
208
  toggle = (
209
- f'<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap">'
210
  f'<div class="toggle-group">'
211
- f'<button class="toggle-btn" data-view="covered"'
212
- f' onclick="airiToggle(\'covered\')">Covered ({covered_n})</button>'
213
- f'<button class="toggle-btn" data-view="gaps"'
214
- f' onclick="airiToggle(\'gaps\')">Gaps ({len(gaps)})</button>'
215
  f'</div>'
216
- f'<span style="font-size:11px;color:{_C["dgray"]}">Click a domain to filter</span>'
217
  f'</div>'
218
  )
219
  table = (
220
  f'<div style="overflow-x:auto"><table class="airi-table">'
221
- f'<thead><tr><th>ID</th><th>Risk</th><th>Domain</th>'
222
- f'<th>Covered by / Note</th></tr></thead>'
223
  f'<tbody>{c_rows}{g_rows}</tbody></table></div>'
224
  )
225
- src = xt(airi.get("airi_version", ""))
226
  bundle_scope = xt(airi.get("airi_bundle_scope", ""))
227
  snapshot = xt(airi.get("airi_upstream_snapshot_date", ""))
228
  license_name = xt(airi.get("airi_upstream_license", ""))
229
  attribution = xt(airi.get("airi_attribution_note", ""))
230
  hint = tip_icon(
231
- "Coverage rate = risks addressed / total risks in detector scope. "
232
- "Gaps are AIRI risks present in scope but not yet covered by any detector. "
233
- "The HTML report uses the curated runtime AIRI bundle, not the full upstream registry."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  )
235
  return (
236
  f'<section id="s4">'
237
- f'<h2 class="s-title">MIT AI Risk Repository Coverage {hint}'
238
- f'<span style="font-size:12px;color:{_C["dgray"]};font-weight:400;margin-left:4px">'
239
- f'{src} | airisk.mit.edu</span></h2>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  f'<div class="panel">'
241
- f'<div style="display:flex;gap:28px;align-items:flex-start;'
242
- f'flex-wrap:wrap;margin-bottom:20px">'
243
- f'<div style="text-align:center;flex-shrink:0">{donut}'
244
- f'<div style="font-size:13px;font-weight:600;color:{_C["navy"]};margin-top:8px">'
245
- f'{covered_n} / {total_n}</div>'
246
- f'<div style="font-size:11px;color:{_C["dgray"]}">risks in scope</div></div>'
247
- f'<div style="flex:1;min-width:240px">'
248
- f'<p style="font-size:12px;color:{_C["dgray"]};margin-bottom:14px;line-height:1.5">'
249
- f'Risks addressed by STEM-BIO-AI detectors across the AIRI V4 '
250
- f'curated runtime bundle. Click a domain card to filter. Toggle covered/gaps.'
251
- f'</p>'
252
- f'<p style="font-size:11px;color:{_C["dgray"]};margin-bottom:0;line-height:1.5">'
253
- f'Bundle scope: {bundle_scope} | snapshot: {snapshot} | license: {license_name}<br>'
254
- f'{attribution}'
255
- f'</p></div></div>'
256
  f'<div class="domain-grid">{domain_boxes}</div>'
257
  f'{toggle}{table}'
 
258
  f'</div></section>'
259
  )
260
 
@@ -268,57 +484,59 @@ def _section5(evidence_ledger: list) -> str:
268
  )
269
  n = len(evidence_ledger)
270
  chips = " ".join(
271
- f'<span class="filter-chip{" active" if i == 0 else ""}"'
272
- f' data-sev="{s}" onclick="filterEv(\'{s}\')">{l}</span>'
273
- for i, (s, l) in enumerate([
274
- ("all", f"All ({n})"),
275
- ("fail", "FAIL"), ("warn", "WARN"), ("pass", "PASS"), ("info", "INFO"),
276
- ])
 
 
 
 
277
  )
278
  rows = "".join(evidence_row(ev) for ev in evidence_ledger[:200])
279
  hint = tip_icon(
280
- "Full evidence ledger from all detectors. "
281
- "Filter by severity. Capped at 200 entries in HTML view."
282
  )
283
  th = f'style="padding:8px 5px;font-size:11px;text-align:left;color:{_C["dgray"]}"'
284
  return (
285
  f'<section id="s5">'
286
  f'<h2 class="s-title">Evidence Detail {hint}</h2>'
287
  f'<div class="panel">'
288
- f'<div style="margin-bottom:14px">{chips}</div>'
289
  f'<div style="overflow-x:auto">'
290
  f'<table style="width:100%;border-collapse:collapse">'
291
  f'<thead><tr style="background:{_C["lgray"]}">'
292
  f'<th style="padding:8px 5px;width:16px"></th>'
293
- f'<th {th}>SEV</th><th {th}>Detector</th>'
294
- f'<th {th}>Finding</th><th {th}>File</th>'
295
  f'</tr></thead>'
296
  f'<tbody>{rows}</tbody></table></div></div></section>'
297
  )
298
 
299
 
300
  def render_html(result: dict[str, Any]) -> str:
301
- score = result["score"]
302
- final = int(score["final_score"])
303
- tier = str(score["formal_tier"])
304
- tc = tier_color(tier)
305
  target = xt(str(result["target"]["name"]))
306
- date = xt(str(result.get("generated_at_local", "")))
307
  s1 = int(score.get("stage_1_readme_intent", 0))
308
  s2 = int(score.get("stage_2_repo_local_consistency", 0))
309
  s3 = int(score.get("stage_3_code_bio", 0))
310
  s4 = int(result.get("replication_score", 0))
311
  t0 = result.get("classification", {}).get("t0_hard_floor", False)
312
 
313
- css = build_css(tc)
314
- nav = _nav()
315
- hero = _hero(score, final, tier, tc, target, date)
316
- sec1 = _section1(result, final, tc, s1, s2, s3, s4, t0)
317
- sec2 = _section2(s1, s2, s3, s4)
318
- sec3 = _section3(result.get("code_integrity", {}), result.get("code_contract", {}))
319
- sec4 = _section4(result.get("airi_risk_coverage", {}))
320
- sec5 = _section5(result.get("evidence_ledger", []))
321
- ver = xt(result.get("stem_ai_version", ""))
322
 
323
  return f"""<!DOCTYPE html>
324
  <html lang="en">
 
1
+ """Interactive HTML report renderer for STEM BIO-AI.
2
 
3
+ Sections: Executive Summary | Decision Path | Code Integrity | AIRI Coverage | Evidence Detail
4
  Self-contained: inline CSS + SVG + JS, zero external dependencies.
5
  """
6
  from __future__ import annotations
 
8
  from typing import Any
9
 
10
  from .render_html_components import (
11
+ _C,
12
+ _STAGE_TIPS,
13
+ xt,
14
+ tier_color,
15
+ tip_icon,
16
+ svg_gauge,
17
+ svg_donut,
18
+ svg_hbar,
19
+ integrity_card,
20
+ domain_card,
21
+ airi_row,
22
+ evidence_row,
23
  )
24
  from .render_html_styles import build_css, JS
25
 
 
37
 
38
  def _nav() -> str:
39
  links = [
40
+ ("#s1", "1. Summary"),
41
+ ("#s2", "2. Decision Path"),
42
+ ("#s3", "3. Code Integrity"),
43
+ ("#s4", "4. AIRI Coverage"),
44
  ("#s5", "5. Evidence"),
45
  ]
46
  items = "".join(f'<a href="{h}" class="nav-link">{l}</a>' for h, l in links)
 
52
  use_scope = xt(str(score.get("use_scope", "")))
53
  return (
54
  f'<header class="hero">'
55
+ f'<div class="hero-left">{gauge}</div>'
56
+ f'<div class="hero-right">'
57
+ f'<div class="eyebrow">STEM BIO-AI Local Audit &nbsp;|&nbsp; {date}</div>'
58
  f'<h1>{target}</h1>'
59
+ f'<div class="hero-meta">'
60
+ f'<span class="tier">{xt(tier)}</span>'
61
+ f'<span class="hero-chip">Deterministic local scan</span>'
62
+ f'<span class="hero-chip">No LLM / no network / no runtime execution</span>'
63
+ f'</div>'
64
+ f'<p class="lede">{use_scope}</p>'
65
  f'</div></header>'
66
  )
67
 
68
 
69
+ def _iter_rubric_rows(rubric: dict[str, Any]) -> list[dict[str, Any]]:
70
+ rows: list[dict[str, Any]] = []
71
+ for key, info in rubric.items():
72
+ if key.endswith("raw_total") or key == "baseline":
73
+ continue
74
+ if not isinstance(info, dict):
75
+ continue
76
+ score = info.get("score")
77
+ max_score = info.get("max")
78
+ if score is None:
79
+ continue
80
+ rows.append(
81
+ {
82
+ "key": key,
83
+ "score": int(score),
84
+ "max": int(max_score) if isinstance(max_score, (int, float)) else None,
85
+ "evidence": xt(str(info.get("evidence", ""))),
86
+ "detector_id": xt(str(info.get("detector_id", ""))) if info.get("detector_id") else "",
87
+ "decision_basis": xt(str(info.get("decision_basis", ""))) if info.get("decision_basis") else "",
88
+ }
89
+ )
90
+ return rows
91
+
92
+
93
+ def _select_focus_rows(
94
+ rubric: dict[str, Any],
95
+ *,
96
+ negatives_first: bool = True,
97
+ limit: int = 4,
98
+ ) -> list[dict[str, Any]]:
99
+ rows = [r for r in _iter_rubric_rows(rubric) if r["score"] != 0]
100
+ if negatives_first:
101
+ negatives = sorted([r for r in rows if r["score"] < 0], key=lambda r: abs(r["score"]), reverse=True)
102
+ positives = sorted([r for r in rows if r["score"] > 0], key=lambda r: abs(r["score"]), reverse=True)
103
+ return (negatives + positives)[:limit]
104
+ return sorted(rows, key=lambda r: abs(r["score"]), reverse=True)[:limit]
105
+
106
+
107
+ def _rubric_focus_list(rows: list[dict[str, Any]]) -> str:
108
+ if not rows:
109
+ return '<li class="focus-line muted">No scored rubric movement surfaced.</li>'
110
+ items = ""
111
+ for row in rows:
112
+ sc = row["score"]
113
+ sign = "+" if sc > 0 else ""
114
+ detector = (
115
+ f'<span class="focus-detector">{row["detector_id"]}</span>'
116
+ if row["detector_id"] else ""
117
+ )
118
+ detail = row["decision_basis"] or row["evidence"]
119
+ items += (
120
+ f'<li class="focus-line">'
121
+ f'<div class="focus-top"><span class="focus-key">{xt(row["key"])}</span>'
122
+ f'<span class="focus-score">{sign}{sc}</span></div>'
123
+ f'<div class="focus-detail">{detector}{detail}</div>'
124
+ f'</li>'
125
  )
126
+ return items
127
+
128
+
129
+ def _code_integrity_summary(integrity: dict[str, Any]) -> tuple[list[tuple[str, dict[str, Any]]], list[tuple[str, dict[str, Any]]]]:
130
+ warnings: list[tuple[str, dict[str, Any]]] = []
131
+ clear: list[tuple[str, dict[str, Any]]] = []
132
+ for key, info in integrity.items():
133
+ if not isinstance(info, dict):
134
+ continue
135
+ if str(info.get("status", "PASS")).upper() == "PASS":
136
+ clear.append((key, info))
137
+ else:
138
+ warnings.append((key, info))
139
+ return warnings, clear
140
+
141
+
142
+ def _section1(result: dict[str, Any], final: int, tc: str,
143
+ s1: int, s2: int, s3: int, s4: int, t0: bool) -> str:
144
+ score = result["score"]
145
+ calibration = result.get("calibration_profile", {})
146
+ freshness = result.get("audit_freshness", {})
147
+ policy_name = xt(str(calibration.get("profile_name", "unknown")))
148
+ policy_status = xt(str(calibration.get("profile_status", "unknown")))
149
+ policy_mode = xt(str(calibration.get("profile_read_mode", "unknown")))
150
+ calibration_note = _calibration_effect_note(calibration)
151
+
152
  stats = "".join(
153
+ f'<div class="metric-card">'
154
+ f'<div class="metric-value" style="color:{c}">{v}</div>'
155
+ f'<div class="metric-label">{l}</div>'
156
+ f'</div>'
157
  for v, l, c in [
158
  (final, "Final Score", tc),
159
  (s1, "S1 Intent", _C["teal"]),
 
162
  (s4, "S4 Replication", _C["green"]),
163
  ]
164
  )
165
+ risks = result.get("notable_risks", [])[:5]
166
+ positives = result.get("notable_positive_evidence", [])[:4]
167
+ stage2_focus = _select_focus_rows(result.get("stage_2r_rubric", {}), negatives_first=True, limit=3)
168
+ stage3_focus = _select_focus_rows(result.get("stage_3_rubric", {}), negatives_first=False, limit=3)
169
+ alert = (
170
+ f'<div class="alert-t0">'
171
+ f'T0 hard floor triggered: direct clinical framing without an explicit boundary declaration. '
172
+ f'Score capped at {final}/100.'
173
+ f'</div>'
174
+ if t0 else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  )
176
+ risk_list = "".join(f'<li>{xt(str(r))}</li>' for r in risks) or "<li>No notable risks surfaced.</li>"
177
+ positive_list = "".join(f'<li>{xt(str(p))}</li>' for p in positives) or "<li>No notable positive evidence surfaced.</li>"
178
  return (
179
  f'<section id="s1">'
180
  f'<h2 class="s-title">Executive Summary</h2>'
181
  f'{alert}'
182
+ f'<div class="metric-grid">{stats}</div>'
183
+ f'<div class="memo-grid">'
184
+ f'<article class="memo-card memo-primary">'
185
+ f'<div class="eyebrow">TL;DR</div>'
186
+ f'<h3>Decision memo</h3>'
187
+ f'<p class="memo-text">This repository lands at <strong>{xt(str(score.get("formal_tier", "")))}</strong> '
188
+ f'with a final score of <strong>{final}/100</strong>. The result is driven more by '
189
+ f'boundary, workflow-support, and governance weaknesses than by classic code-pattern failures.</p>'
190
+ f'<div class="pill-row"><span class="pill">Policy: {policy_name}</span>'
191
+ f'<span class="pill">Status: {policy_status}</span>'
192
+ f'<span class="pill">Mode: {policy_mode}</span></div>'
193
+ f'</article>'
194
+ f'<article class="memo-card">'
195
+ f'<div class="eyebrow">Primary Risks</div>'
196
+ f'<h3>What pushed the review down</h3>'
197
+ f'<ul class="memo-list">{risk_list}</ul>'
198
+ f'</article>'
199
+ f'<article class="memo-card">'
200
+ f'<div class="eyebrow">Positive Evidence</div>'
201
+ f'<h3>What still supports reviewability</h3>'
202
+ f'<ul class="memo-list">{positive_list}</ul>'
203
+ f'</article>'
204
+ f'<article class="memo-card">'
205
+ f'<div class="eyebrow">Freshness</div>'
206
+ f'<h3>When to re-check</h3>'
207
+ f'<p class="memo-text">Review after <strong>{xt(str(freshness.get("review_after_days", "n/a")))}</strong> days. '
208
+ f'Expires on <strong>{xt(str(freshness.get("expires_on", "unknown")))}</strong>.</p>'
209
+ f'<p class="memo-note">Change-triggered re-audit now: '
210
+ f'{xt(str(freshness.get("change_triggered_reaudit_recommended_now", False)))}</p>'
211
+ f'</article>'
212
+ f'</div>'
213
+ f'<div class="memo-grid" style="margin-top:18px">'
214
+ f'<article class="memo-card">'
215
+ f'<div class="eyebrow">Stage 2R Focus</div>'
216
+ f'<h3>Repo-local contradictions</h3>'
217
+ f'<ul class="focus-list">{_rubric_focus_list(stage2_focus)}</ul>'
218
+ f'</article>'
219
+ f'<article class="memo-card">'
220
+ f'<div class="eyebrow">Stage 3 Focus</div>'
221
+ f'<h3>Accountability surfaces</h3>'
222
+ f'<ul class="focus-list">{_rubric_focus_list(stage3_focus)}</ul>'
223
+ f'</article>'
224
+ f'<article class="memo-card">'
225
+ f'<div class="eyebrow">Policy Boundary</div>'
226
+ f'<h3>How to read this artifact</h3>'
227
+ f'<p class="memo-text">{xt(calibration_note) if calibration_note else "Authoritative scoring and surfaced policy metadata are aligned in this release line."}</p>'
228
+ f'</article>'
229
+ f'</div>'
230
  f'</section>'
231
  )
232
 
233
 
234
+ def _stage_card(title: str, value: int, color: str, tip: str, focus_rows: list[dict[str, Any]], summary: str) -> str:
235
+ bar_color = _C["red"] if value < 40 else (_C["amber"] if value < 65 else color)
236
+ return (
237
+ f'<article class="stage-card">'
238
+ f'<div class="stage-card-top">'
239
+ f'<div class="stage-name">{title} {tip_icon(tip)}</div>'
240
+ f'<div class="stage-value" style="color:{bar_color}">{value}</div>'
241
+ f'</div>'
242
+ f'{svg_hbar(value, 100, bar_color)}'
243
+ f'<p class="stage-summary">{xt(summary)}</p>'
244
+ f'<ul class="focus-list">{_rubric_focus_list(focus_rows)}</ul>'
245
+ f'</article>'
246
+ )
247
+
248
+
249
+ def _config_pattern_card() -> str:
250
+ return (
251
+ f'<div class="config-pattern">'
252
+ f'<div class="config-copy">'
253
+ f'<div class="eyebrow">Configured, Not Rewritten</div>'
254
+ f'<h3 class="subhead">Changing review posture does not require touching the score core</h3>'
255
+ f'<p class="memo-text">Use <code>stem policy simulate</code> with a governed profile file when you want to preview a different review posture. '
256
+ f'The authoritative score path stays deterministic; the profile is surfaced as metadata and preview-only interpretation.</p>'
257
+ f'<p class="memo-note">If you only need the default posture, you do not need a profile file at all.</p>'
258
+ f'</div>'
259
+ f'<div class="config-grid">'
260
+ f'<article class="config-card">'
261
+ f'<div class="config-label">profile.json</div>'
262
+ f'<pre class="config-code">{{\n "profile_name": "strict_clinical_adjacency",\n "profile_read_mode": "mirror_only"\n}}</pre>'
263
+ f'</article>'
264
+ f'<article class="config-card">'
265
+ f'<div class="config-label">command</div>'
266
+ f'<pre class="config-code">stem policy simulate /path/to/repo --profile-file profile.json</pre>'
267
+ f'</article>'
268
+ f'<article class="config-card">'
269
+ f'<div class="config-label">artifact note</div>'
270
+ f'<pre class="config-code">Calibration Effect: mirror-only\nPolicy metadata surfaced\nFormal score unchanged</pre>'
271
+ f'</article>'
272
+ f'</div>'
273
+ f'</div>'
274
  )
275
+
276
+
277
+ def _section2(result: dict[str, Any], s1: int, s2: int, s3: int, s4: int) -> str:
278
+ stage1_focus = _select_focus_rows(result.get("stage_1_rubric", {}), negatives_first=True, limit=4)
279
+ stage2_focus = _select_focus_rows(result.get("stage_2r_rubric", {}), negatives_first=True, limit=4)
280
+ stage3_focus = _select_focus_rows(result.get("stage_3_rubric", {}), negatives_first=False, limit=4)
281
+ stage4_focus = _select_focus_rows(result.get("stage_4_rubric", {}), negatives_first=False, limit=4)
282
+ cards = "".join([
283
+ _stage_card(
284
+ "Stage 1 — README Intent",
285
+ s1,
286
+ _C["teal"],
287
+ _STAGE_TIPS[0],
288
+ stage1_focus,
289
+ "Claim language, limitation posture, and clinical boundary wording.",
290
+ ),
291
+ _stage_card(
292
+ "Stage 2R — Repo Consistency",
293
+ s2,
294
+ _C["purple"],
295
+ _STAGE_TIPS[1],
296
+ stage2_focus,
297
+ "Internal contradictions between README, workflow claims, and support surfaces.",
298
+ ),
299
+ _stage_card(
300
+ "Stage 3 — Code / Bio Responsibility",
301
+ s3,
302
+ _C["slate"],
303
+ _STAGE_TIPS[2],
304
+ stage3_focus,
305
+ "Engineering accountability, provenance, and reviewable responsibility surfaces.",
306
+ ),
307
+ _stage_card(
308
+ "Stage 4 — Replication",
309
+ s4,
310
+ _C["green"],
311
+ _STAGE_TIPS[3],
312
+ stage4_focus,
313
+ "Reproducibility evidence is reported separately and does not alter the formal tier.",
314
+ ),
315
+ ])
316
  return (
317
  f'<section id="s2">'
318
+ f'<h2 class="s-title">Decision Path {tip_icon("This section explains where the score came from, with rubric-level movement and detector-linked rationale where available.")}</h2>'
319
+ f'<div class="panel decision-panel">'
320
+ f'<div class="formula-banner">Final = 0.4 × S1 + 0.2 × S2R + 0.4 × S3 − C1_penalty &nbsp;|&nbsp; Stage 4 remains a separate replication lane.</div>'
321
+ f'{_config_pattern_card()}'
322
+ f'<div class="stage-deck">{cards}</div>'
323
+ f'</div></section>'
 
324
  )
325
 
326
 
 
332
  "status": v.get("status", "PASS"),
333
  "evidence": [f"count={v.get('count', 0)}"],
334
  }
335
+ warn_pairs, pass_pairs = _code_integrity_summary(combined)
336
+ warning_cards = "".join(integrity_card(k, v) for k, v in warn_pairs)
337
+ pass_cards = "".join(integrity_card(k, v) for k, v in pass_pairs)
338
  hint = tip_icon(
339
+ "C1-C6: static code and governance checks. CC1-CC3: Layer 2 AST contract detectors. "
340
+ "PASS means no mapped trigger was detected in the current rule scope, not that the whole repository is mature."
341
+ )
342
+ faq = (
343
+ f'<div class="faq-block">'
344
+ f'<details class="faq-item"><summary>Why can Code Integrity contain PASS while the overall score is still low?</summary>'
345
+ f'<p>Because Code Integrity is a narrow detector family. The formal score is still driven mainly by Stage 1, Stage 2R, and Stage 3 evidence posture.</p></details>'
346
+ f'<details class="faq-item"><summary>What changed in the C4 / C5 / C6 split?</summary>'
347
+ f'<p>C4 is now reserved for executable fail-open exception behavior, C5 for unsupported compliance or boundary integrity claims, and C6 for mock-auth or no-auth trust-boundary signals.</p></details>'
348
+ f'</div>'
349
  )
350
  return (
351
  f'<section id="s3">'
352
  f'<h2 class="s-title">Code Integrity &amp; Contract {hint}</h2>'
353
+ f'<div class="integrity-layout">'
354
+ f'<div class="panel">'
355
+ f'<div class="eyebrow">Warnings First</div>'
356
+ f'<h3 class="subhead">Mapped risk lanes that fired</h3>'
357
+ f'<div class="card-grid">{warning_cards or "<p class=\"memo-text\">No WARN/FAIL lanes surfaced.</p>"}</div>'
358
+ f'</div>'
359
+ f'<div class="panel">'
360
+ f'<div class="eyebrow">Clear Lanes</div>'
361
+ f'<h3 class="subhead">What stayed quiet in the current rule scope</h3>'
362
+ f'<div class="card-grid">{pass_cards}</div>'
363
+ f'{faq}'
364
+ f'</div>'
365
+ f'</div></section>'
366
  )
367
 
368
 
369
  def _section4(airi: dict) -> str:
370
  if not airi or "covered_risks" not in airi:
371
  return ""
372
+ pct = float(airi.get("coverage_rate", 0))
373
  covered_n = int(airi.get("covered_count", 0))
374
+ total_n = int(airi.get("total_risks_in_detector_scope", 0))
375
+ gaps = airi.get("known_gaps", [])
376
  all_risks = airi.get("covered_risks", [])
377
+ donut = svg_donut(pct, _C["green"], 98)
378
 
 
379
  domain_counts: dict[int, int] = {d: 0 for d in range(1, 8)}
380
  for r in all_risks:
381
  try:
 
389
  c_rows = "".join(airi_row(r, "covered") for r in all_risks[:24])
390
  g_rows = "".join(airi_row(g, "gap") for g in gaps)
391
  toggle = (
392
+ f'<div class="toggle-row">'
393
  f'<div class="toggle-group">'
394
+ f'<button class="toggle-btn" data-view="covered" onclick="airiToggle(\'covered\')">Covered ({covered_n})</button>'
395
+ f'<button class="toggle-btn" data-view="gaps" onclick="airiToggle(\'gaps\')">Gaps ({len(gaps)})</button>'
 
 
396
  f'</div>'
397
+ f'<span class="muted-note">Click a domain to filter.</span>'
398
  f'</div>'
399
  )
400
  table = (
401
  f'<div style="overflow-x:auto"><table class="airi-table">'
402
+ f'<thead><tr><th>ID</th><th>Risk</th><th>Domain</th><th>Covered by / Note</th></tr></thead>'
 
403
  f'<tbody>{c_rows}{g_rows}</tbody></table></div>'
404
  )
405
+ src = xt(airi.get("airi_version", ""))
406
  bundle_scope = xt(airi.get("airi_bundle_scope", ""))
407
  snapshot = xt(airi.get("airi_upstream_snapshot_date", ""))
408
  license_name = xt(airi.get("airi_upstream_license", ""))
409
  attribution = xt(airi.get("airi_attribution_note", ""))
410
  hint = tip_icon(
411
+ "Coverage counts only risks reached through local detector mappings. "
412
+ "Coverage is not a safety verdict, and unmapped review concerns remain outside the numerator."
413
+ )
414
+ faq = (
415
+ f'<div class="faq-block" style="margin-top:18px">'
416
+ f'<details class="faq-item"><summary>What does 7 / 32 mean?</summary>'
417
+ f'<p>It means seven AIRI risk IDs are currently reached by active local detector mappings, out of thirty-two AIRI risk IDs in the current detector scope.</p></details>'
418
+ f'<details class="faq-item"><summary>What does “why mapped” mean?</summary>'
419
+ f'<p>Each covered AIRI row carries a bounded explanation built from the triggered detector, the local mapping justification, and the trigger reason surfaced by the scan.</p></details>'
420
+ f'<details class="faq-item"><summary>What does AIRI not prove here?</summary>'
421
+ f'<p>AIRI does not independently verify harm, causality, clinical failure, or legal noncompliance. It is a risk-vocabulary layer around local findings.</p></details>'
422
+ f'</div>'
423
+ )
424
+ mapping_pattern = (
425
+ f'<div class="config-pattern" style="margin-top:18px">'
426
+ f'<div class="config-copy">'
427
+ f'<div class="eyebrow">Mapped, Not Guessed</div>'
428
+ f'<h3 class="subhead">AIRI rows light up through active detector mappings</h3>'
429
+ f'<p class="memo-text">The report does not infer AIRI coverage from prose alone. '
430
+ f'Coverage appears when a local detector fires and a governed mapping exists in the current AIRI runtime bundle.</p>'
431
+ f'</div>'
432
+ f'<div class="config-grid">'
433
+ f'<article class="config-card">'
434
+ f'<div class="config-label">trigger</div>'
435
+ f'<pre class="config-code">C6_mock_auth_or_fail_open_boundary\nstatus: detected</pre>'
436
+ f'</article>'
437
+ f'<article class="config-card">'
438
+ f'<div class="config-label">mapping</div>'
439
+ f'<pre class="config-code">R2R_D5_single_external_service_dependency\n→ 72.04.02 Market Concentration</pre>'
440
+ f'</article>'
441
+ f'<article class="config-card">'
442
+ f'<div class="config-label">report surface</div>'
443
+ f'<pre class="config-code">covered_by: detector id\nwhy: bounded mapping reason</pre>'
444
+ f'</article>'
445
+ f'</div>'
446
+ f'</div>'
447
  )
448
  return (
449
  f'<section id="s4">'
450
+ f'<h2 class="s-title">MIT AI Risk Repository Coverage {hint}<span class="airi-tag">{src} | airisk.mit.edu</span></h2>'
451
+ f'<div class="airi-layout">'
452
+ f'<div class="panel">'
453
+ f'<div class="eyebrow">Feature Explainer</div>'
454
+ f'<h3 class="subhead">What this section is doing</h3>'
455
+ f'<p class="memo-text">AIRI is used here as a bounded risk-vocabulary layer around deterministic repository findings. '
456
+ f'The report uses the curated runtime bundle, not the full upstream AIRI universe.</p>'
457
+ f'<div class="airi-kpi">'
458
+ f'<div class="airi-donut">{donut}</div>'
459
+ f'<div class="airi-copy">'
460
+ f'<div class="metric-inline"><strong>{covered_n} / {total_n}</strong> risks in detector scope</div>'
461
+ f'<div class="metric-inline">Bundle scope: <strong>{bundle_scope}</strong></div>'
462
+ f'<div class="metric-inline">Snapshot: <strong>{snapshot}</strong> | License: <strong>{license_name}</strong></div>'
463
+ f'<p class="memo-note">{attribution}</p>'
464
+ f'</div></div>'
465
+ f'{faq}'
466
+ f'{mapping_pattern}'
467
+ f'</div>'
468
  f'<div class="panel">'
469
+ f'<div class="eyebrow">Coverage Explorer</div>'
470
+ f'<h3 class="subhead">Covered and gap rows</h3>'
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  f'<div class="domain-grid">{domain_boxes}</div>'
472
  f'{toggle}{table}'
473
+ f'</div>'
474
  f'</div></section>'
475
  )
476
 
 
484
  )
485
  n = len(evidence_ledger)
486
  chips = " ".join(
487
+ f'<span class="filter-chip{" active" if i == 0 else ""}" data-sev="{s}" onclick="filterEv(\'{s}\')">{l}</span>'
488
+ for i, (s, l) in enumerate(
489
+ [
490
+ ("all", f"All ({n})"),
491
+ ("fail", "FAIL"),
492
+ ("warn", "WARN"),
493
+ ("pass", "PASS"),
494
+ ("info", "INFO"),
495
+ ]
496
+ )
497
  )
498
  rows = "".join(evidence_row(ev) for ev in evidence_ledger[:200])
499
  hint = tip_icon(
500
+ "Full evidence ledger from all detectors. Filter by severity. Capped at 200 entries in HTML view."
 
501
  )
502
  th = f'style="padding:8px 5px;font-size:11px;text-align:left;color:{_C["dgray"]}"'
503
  return (
504
  f'<section id="s5">'
505
  f'<h2 class="s-title">Evidence Detail {hint}</h2>'
506
  f'<div class="panel">'
507
+ f'<div class="toggle-row" style="margin-bottom:14px">{chips}</div>'
508
  f'<div style="overflow-x:auto">'
509
  f'<table style="width:100%;border-collapse:collapse">'
510
  f'<thead><tr style="background:{_C["lgray"]}">'
511
  f'<th style="padding:8px 5px;width:16px"></th>'
512
+ f'<th {th}>SEV</th><th {th}>Detector</th><th {th}>Finding</th><th {th}>File</th>'
 
513
  f'</tr></thead>'
514
  f'<tbody>{rows}</tbody></table></div></div></section>'
515
  )
516
 
517
 
518
  def render_html(result: dict[str, Any]) -> str:
519
+ score = result["score"]
520
+ final = int(score["final_score"])
521
+ tier = str(score["formal_tier"])
522
+ tc = tier_color(tier)
523
  target = xt(str(result["target"]["name"]))
524
+ date = xt(str(result.get("generated_at_local", "")))
525
  s1 = int(score.get("stage_1_readme_intent", 0))
526
  s2 = int(score.get("stage_2_repo_local_consistency", 0))
527
  s3 = int(score.get("stage_3_code_bio", 0))
528
  s4 = int(result.get("replication_score", 0))
529
  t0 = result.get("classification", {}).get("t0_hard_floor", False)
530
 
531
+ css = build_css(tc)
532
+ nav = _nav()
533
+ hero = _hero(score, final, tier, tc, target, date)
534
+ sec1 = _section1(result, final, tc, s1, s2, s3, s4, t0)
535
+ sec2 = _section2(result, s1, s2, s3, s4)
536
+ sec3 = _section3(result.get("code_integrity", {}), result.get("code_contract", {}))
537
+ sec4 = _section4(result.get("airi_risk_coverage", {}))
538
+ sec5 = _section5(result.get("evidence_ledger", []))
539
+ ver = xt(result.get("stem_ai_version", ""))
540
 
541
  return f"""<!DOCTYPE html>
542
  <html lang="en">
stem_ai/render_html_styles.py CHANGED
@@ -1,5 +1,6 @@
1
  """CSS and JS template strings for STEM BIO-AI interactive HTML report."""
2
  from __future__ import annotations
 
3
  from .render_html_components import _C
4
 
5
 
@@ -8,109 +9,238 @@ def build_css(tc: str) -> str:
8
  g, a, r = _C["green"], _C["amber"], _C["red"]
9
  p, lg, mg, dg, w = _C["purple"], _C["lgray"], _C["mgray"], _C["dgray"], _C["white"]
10
  return f"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
12
  html{{scroll-behavior:smooth}}
13
- body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
14
- background:{lg};color:{n};font-size:14px;line-height:1.5}}
15
- .nav{{position:sticky;top:0;z-index:50;background:{n};display:flex;
16
- border-bottom:2px solid {t};overflow-x:auto}}
17
- .nav-link{{padding:12px 18px;color:rgba(255,255,255,.6);font-size:12px;font-weight:600;
18
- text-decoration:none;transition:color .2s,background .2s;
19
- border-bottom:3px solid transparent;margin-bottom:-2px;white-space:nowrap}}
20
- .nav-link:hover{{color:#fff;background:rgba(255,255,255,.07)}}
21
- .nav-link.active{{color:#fff;border-bottom-color:{t}}}
22
- .hero{{background:linear-gradient(135deg,{n} 0%,#1A3556 100%);color:#fff;
23
- padding:36px 48px;display:flex;gap:36px;align-items:center;flex-wrap:wrap}}
24
- .hero h1{{font-size:24px;font-weight:700;margin-bottom:6px}}
25
- .sub{{font-size:12px;color:rgba(255,255,255,.5);margin-bottom:10px}}
26
- .tier{{display:inline-block;background:{tc};color:#fff;font-size:12px;font-weight:700;
27
- padding:4px 14px;border-radius:20px;margin-bottom:10px}}
28
- .content{{max-width:980px;margin:0 auto;padding:36px 24px}}
29
- section{{margin-bottom:40px;scroll-margin-top:60px}}
30
- .s-title{{font-size:18px;font-weight:700;color:{n};margin-bottom:18px;
31
- display:flex;align-items:center;gap:8px;flex-wrap:wrap}}
32
- .panel{{background:{w};border-radius:10px;padding:24px;
33
- box-shadow:0 1px 4px rgba(0,0,0,.08)}}
34
- .card-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}}
35
- .card{{background:{w};border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.08);
36
- cursor:pointer;transition:box-shadow .2s,transform .15s;overflow:hidden;
37
- outline:none}}
38
- .card:hover{{box-shadow:0 6px 20px rgba(0,0,0,.14);transform:translateY(-2px)}}
39
- .card:focus{{box-shadow:0 0 0 3px {t}40}}
40
- .card.expanded{{box-shadow:0 6px 24px rgba(0,0,0,.16)}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  .card.expanded .caret{{transform:rotate(180deg)}}
42
  .caret{{display:block;transition:transform .2s}}
43
- .tip-icon{{display:inline-flex;align-items:center;justify-content:center;
44
- width:16px;height:16px;border-radius:50%;background:{mg};color:{dg};
45
- font-size:10px;font-weight:700;cursor:help;position:relative;
46
- flex-shrink:0;vertical-align:middle}}
 
 
47
  [data-tooltip]{{position:relative}}
48
- [data-tooltip]::after{{content:attr(data-tooltip);position:absolute;
49
- bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);
50
- background:{n};color:#fff;padding:8px 12px;border-radius:6px;
51
- font-size:11px;line-height:1.4;white-space:normal;max-width:240px;
52
- width:max-content;opacity:0;pointer-events:none;transition:opacity .2s;
53
- z-index:200;text-align:left;font-weight:400;
54
- box-shadow:0 4px 12px rgba(0,0,0,.25)}}
55
  [data-tooltip]:hover::after,[data-tooltip]:focus::after{{opacity:1}}
56
- .toggle-group{{display:flex;border:1px solid {mg};border-radius:6px;
57
- overflow:hidden;width:fit-content;margin-bottom:16px}}
58
- .toggle-btn{{padding:7px 18px;font-size:12px;font-weight:600;color:{dg};
59
- background:{w};border:none;cursor:pointer;transition:all .2s}}
60
  .toggle-btn:hover{{background:{lg}}}
61
  .toggle-btn.active{{background:{n};color:#fff}}
62
- .airi-table{{width:100%;border-collapse:collapse}}
63
- .domain-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));
64
- gap:10px;margin-bottom:20px}}
65
- .domain-card{{display:flex;align-items:center;gap:10px;padding:10px 14px;
66
- background:{w};border:1.5px solid {mg};border-radius:8px;
67
- transition:border-color .15s,box-shadow .15s;user-select:none}}
68
  .domain-card:hover{{border-color:{t};box-shadow:0 2px 8px rgba(0,0,0,.1)}}
69
- .domain-card.domain-active{{border-color:var(--d-color,{t});
70
- box-shadow:0 0 0 3px color-mix(in srgb,var(--d-color,{t}) 20%,transparent)}}
71
- .domain-num{{display:inline-flex;align-items:center;justify-content:center;
72
- width:22px;height:22px;border-radius:5px;color:#fff;font-size:11px;
73
- font-weight:700;flex-shrink:0}}
74
- .domain-label{{font-size:11px;color:{n};font-weight:500;flex:1;line-height:1.3}}
75
- .domain-cnt{{font-size:12px;font-weight:700;flex-shrink:0}}
76
- .airi-table th{{padding:10px 8px;font-size:11px;text-align:left;color:{dg};
77
- background:{lg};font-weight:600;text-transform:uppercase;letter-spacing:.05em}}
 
78
  .airi-table tr:hover{{background:{lg}}}
79
  .ev-row{{transition:background .15s;cursor:default}}
80
  .ev-row:hover{{background:{lg}}}
81
- .alert-t0{{background:{r};color:#fff;padding:14px 24px;border-radius:8px;
82
- margin-bottom:24px;font-weight:600;font-size:13px;
83
- border-left:4px solid rgba(0,0,0,.2)}}
84
- .stats-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
85
- gap:14px;margin-bottom:28px}}
86
- .stat-card{{background:{w};border-radius:8px;padding:18px 16px;
87
- box-shadow:0 1px 3px rgba(0,0,0,.07);text-align:center;
88
- transition:box-shadow .2s}}
89
- .stat-card:hover{{box-shadow:0 4px 12px rgba(0,0,0,.12)}}
90
- .stat-val{{font-size:28px;font-weight:700;margin-bottom:4px}}
91
- .stat-lbl{{font-size:11px;color:{dg};font-weight:600;text-transform:uppercase;
92
- letter-spacing:.05em}}
93
- .risk-item{{padding:12px 16px;border-left:4px solid {a};margin-bottom:10px;
94
- background:{w};border-radius:0 8px 8px 0;font-size:12px;color:{n};
95
- line-height:1.5;transition:border-color .2s,box-shadow .2s}}
96
- .risk-item:hover{{border-left-color:{r};box-shadow:0 2px 8px rgba(0,0,0,.08)}}
97
- .filter-chip{{display:inline-flex;align-items:center;padding:4px 12px;
98
- border-radius:14px;font-size:11px;font-weight:600;cursor:pointer;
99
- border:1px solid {mg};margin:0 6px 8px 0;transition:all .15s;
100
- background:{w};user-select:none}}
101
  .filter-chip.active{{background:{n};color:#fff;border-color:{n}}}
102
  .filter-chip:hover:not(.active){{background:{lg}}}
103
- .stage-row{{margin-bottom:18px}}
104
- .footer{{font-size:11px;color:{dg};text-align:center;padding:20px 0;
105
- border-top:1px solid {mg};margin-top:40px}}
106
- .formula{{font-size:11px;color:{dg};margin-top:16px;font-family:monospace;
107
- background:{lg};padding:8px 12px;border-radius:6px}}
108
- @media(max-width:640px){{
109
- .hero{{padding:24px;flex-direction:column}}
110
- .content{{padding:24px 16px}}
111
- .nav-link{{padding:10px 12px;font-size:11px}}
112
- .stats-grid{{grid-template-columns:repeat(2,1fr)}}
113
- }}"""
 
 
 
 
 
 
 
 
 
 
114
 
115
 
116
  JS = r"""
@@ -126,7 +256,7 @@ var _airiDomain = 0;
126
 
127
  function _applyAiriFilter() {
128
  document.querySelectorAll('.airi-covered,.airi-gaps').forEach(function(r) {
129
- var viewOk = r.classList.contains('airi-' + _airiView);
130
  var domainOk = (_airiDomain === 0 || r.dataset.domain == _airiDomain);
131
  r.style.display = (viewOk && domainOk) ? '' : 'none';
132
  });
@@ -145,8 +275,7 @@ function filterDomain(num) {
145
  document.querySelectorAll('.domain-card').forEach(function(c) {
146
  var active = (c.dataset.domain == _airiDomain);
147
  c.classList.toggle('domain-active', active);
148
- c.style.setProperty('--d-color', active
149
- ? c.querySelector('.domain-num').style.background : '');
150
  });
151
  _applyAiriFilter();
152
  }
@@ -163,7 +292,7 @@ function filterEv(sev) {
163
  function updateNav() {
164
  var cur = '';
165
  document.querySelectorAll('section[id]').forEach(function(s) {
166
- if (window.scrollY >= s.offsetTop - 80) cur = s.id;
167
  });
168
  document.querySelectorAll('.nav-link').forEach(function(l) {
169
  l.classList.toggle('active', l.getAttribute('href') === '#' + cur);
 
1
  """CSS and JS template strings for STEM BIO-AI interactive HTML report."""
2
  from __future__ import annotations
3
+
4
  from .render_html_components import _C
5
 
6
 
 
9
  g, a, r = _C["green"], _C["amber"], _C["red"]
10
  p, lg, mg, dg, w = _C["purple"], _C["lgray"], _C["mgray"], _C["dgray"], _C["white"]
11
  return f"""
12
+ :root{{
13
+ --navy:{n};
14
+ --teal:{t};
15
+ --green:{g};
16
+ --amber:{a};
17
+ --red:{r};
18
+ --purple:{p};
19
+ --slate:{_C["slate"]};
20
+ --bg:{lg};
21
+ --panel:{w};
22
+ --line:{mg};
23
+ --muted:{dg};
24
+ --tier:{tc};
25
+ --shadow-soft:0 10px 28px rgba(13,31,60,.08);
26
+ --shadow-strong:0 20px 48px rgba(13,31,60,.16);
27
+ --radius:18px;
28
+ }}
29
  *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
30
  html{{scroll-behavior:smooth}}
31
+ body{{
32
+ font-family:"Segoe UI Variable Text","Segoe UI",ui-sans-serif,sans-serif;
33
+ background:
34
+ radial-gradient(circle at top left, rgba(26,111,168,.09), transparent 28%),
35
+ linear-gradient(180deg, #f5f8fc 0%, {lg} 42%, #eef3f9 100%);
36
+ color:{n};
37
+ font-size:14px;
38
+ line-height:1.6;
39
+ }}
40
+ h1,h2,h3,.stage-name,.metric-value,.tier{{
41
+ font-family:"Iowan Old Style","Palatino Linotype","Book Antiqua",Georgia,serif;
42
+ }}
43
+ .nav{{
44
+ position:sticky;top:0;z-index:60;
45
+ background:rgba(255,255,255,.86);
46
+ backdrop-filter:blur(14px);
47
+ display:flex;gap:4px;overflow-x:auto;
48
+ border-bottom:1px solid rgba(13,31,60,.08);
49
+ box-shadow:0 8px 24px rgba(13,31,60,.06);
50
+ }}
51
+ .nav-link{{
52
+ padding:14px 18px;color:{dg};font-size:12px;font-weight:700;
53
+ text-decoration:none;transition:color .2s,background .2s,border-color .2s;
54
+ border-bottom:3px solid transparent;white-space:nowrap;
55
+ }}
56
+ .nav-link:hover{{color:{n};background:rgba(13,31,60,.04)}}
57
+ .nav-link.active{{color:{n};border-bottom-color:{t}}}
58
+ .hero{{
59
+ background:
60
+ radial-gradient(circle at top right, rgba(255,255,255,.12), transparent 26%),
61
+ linear-gradient(135deg, {n} 0%, #163457 48%, #24537B 100%);
62
+ color:#fff;
63
+ padding:42px 48px 46px;
64
+ display:grid;
65
+ grid-template-columns:minmax(180px,220px) 1fr;
66
+ gap:30px;
67
+ align-items:center;
68
+ }}
69
+ .hero-left{{display:flex;justify-content:center}}
70
+ .hero-right{{display:flex;flex-direction:column;gap:10px}}
71
+ .hero h1{{font-size:34px;font-weight:700;line-height:1.1;letter-spacing:-.02em}}
72
+ .eyebrow{{font-size:11px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:rgba(13,31,60,.62)}}
73
+ .hero .eyebrow{{color:rgba(255,255,255,.62)}}
74
+ .hero-meta{{display:flex;gap:10px;flex-wrap:wrap;align-items:center}}
75
+ .tier{{
76
+ display:inline-block;background:{tc};color:#fff;font-size:13px;font-weight:700;
77
+ padding:6px 16px;border-radius:999px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.12);
78
+ }}
79
+ .hero-chip{{
80
+ display:inline-flex;align-items:center;padding:6px 12px;border-radius:999px;
81
+ border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.08);
82
+ color:rgba(255,255,255,.84);font-size:11px;font-weight:700;
83
+ }}
84
+ .lede{{font-size:14px;color:rgba(255,255,255,.82);max-width:720px}}
85
+ .content{{max-width:1180px;margin:0 auto;padding:34px 24px 54px}}
86
+ section{{margin-bottom:34px;scroll-margin-top:72px}}
87
+ .s-title{{font-size:24px;font-weight:700;color:{n};margin-bottom:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}}
88
+ .subhead{{font-size:22px;font-weight:700;color:{n};margin:2px 0 10px}}
89
+ .panel{{
90
+ background:{w};
91
+ border:1px solid rgba(13,31,60,.07);
92
+ border-radius:var(--radius);
93
+ padding:24px;
94
+ box-shadow:var(--shadow-soft);
95
+ }}
96
+ .memo-grid,.integrity-layout,.airi-layout{{display:grid;grid-template-columns:repeat(12,1fr);gap:18px}}
97
+ .memo-card,.stage-card{{
98
+ background:{w};
99
+ border:1px solid rgba(13,31,60,.08);
100
+ border-radius:var(--radius);
101
+ padding:20px 20px 18px;
102
+ box-shadow:var(--shadow-soft);
103
+ }}
104
+ .memo-primary{{background:linear-gradient(180deg,#ffffff 0%,#f8fbff 100%)}}
105
+ .memo-card:nth-child(1){{grid-column:span 6}}
106
+ .memo-card:nth-child(2),.memo-card:nth-child(3),.memo-card:nth-child(4){{grid-column:span 6}}
107
+ .memo-text{{font-size:13px;color:{n}}}
108
+ .memo-note,.muted-note,.muted{{font-size:11px;color:{dg};line-height:1.5}}
109
+ .memo-list{{padding-left:18px;display:grid;gap:8px;font-size:12px;color:{n}}}
110
+ .pill-row{{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}}
111
+ .pill{{display:inline-flex;align-items:center;padding:5px 11px;border-radius:999px;background:{lg};border:1px solid {mg};font-size:11px;font-weight:700;color:{n}}}
112
+ .metric-grid{{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:14px;margin-bottom:18px}}
113
+ .metric-card{{
114
+ background:{w};border:1px solid rgba(13,31,60,.08);border-radius:16px;
115
+ padding:18px 16px;box-shadow:var(--shadow-soft);text-align:center
116
+ }}
117
+ .metric-value{{font-size:32px;font-weight:700;line-height:1;margin-bottom:6px}}
118
+ .metric-label{{font-size:11px;color:{dg};font-weight:800;text-transform:uppercase;letter-spacing:.08em}}
119
+ .alert-t0{{background:{r};color:#fff;padding:15px 18px;border-radius:14px;margin-bottom:18px;font-weight:700;box-shadow:var(--shadow-soft)}}
120
+ .decision-panel{{padding:18px}}
121
+ .formula-banner{{
122
+ margin-bottom:16px;padding:12px 14px;border-radius:14px;background:#eef5fb;
123
+ border:1px solid #d7e5f4;font-size:12px;color:{n};font-family:"Consolas","SFMono-Regular",monospace
124
+ }}
125
+ .config-pattern{{
126
+ display:grid;
127
+ grid-template-columns:minmax(0,1.2fr) minmax(0,1.8fr);
128
+ gap:18px;
129
+ margin-bottom:18px;
130
+ padding:18px;
131
+ border-radius:16px;
132
+ background:linear-gradient(180deg,#fbfdff 0%,#f2f7fc 100%);
133
+ border:1px solid #dbe6f1;
134
+ }}
135
+ .config-copy{{display:grid;gap:10px;align-content:start}}
136
+ .config-grid{{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}}
137
+ .config-card{{
138
+ background:{w};
139
+ border:1px solid rgba(13,31,60,.08);
140
+ border-radius:14px;
141
+ padding:12px;
142
+ box-shadow:var(--shadow-soft);
143
+ }}
144
+ .config-label{{
145
+ font-size:10px;
146
+ font-weight:800;
147
+ letter-spacing:.12em;
148
+ text-transform:uppercase;
149
+ color:{dg};
150
+ margin-bottom:8px;
151
+ }}
152
+ .config-code{{
153
+ font-family:"Consolas","SFMono-Regular",monospace;
154
+ font-size:11px;
155
+ line-height:1.55;
156
+ white-space:pre-wrap;
157
+ color:{n};
158
+ background:#f7fafc;
159
+ border:1px solid #e3ebf3;
160
+ border-radius:10px;
161
+ padding:10px 11px;
162
+ overflow:auto;
163
+ }}
164
+ .stage-deck{{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}}
165
+ .stage-card-top{{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:10px}}
166
+ .stage-name{{font-size:18px;font-weight:700;color:{n};line-height:1.2}}
167
+ .stage-value{{font-size:26px;font-weight:700;line-height:1}}
168
+ .stage-summary{{font-size:12px;color:{dg};margin:12px 0 10px}}
169
+ .focus-list{{list-style:none;display:grid;gap:10px}}
170
+ .focus-line{{padding-top:10px;border-top:1px solid rgba(13,31,60,.08)}}
171
+ .focus-top{{display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-bottom:4px}}
172
+ .focus-key{{font-size:12px;font-weight:700;color:{n}}}
173
+ .focus-score{{font-size:12px;font-weight:800;color:{n}}}
174
+ .focus-detail{{font-size:11px;color:{dg};line-height:1.55}}
175
+ .focus-detector{{display:inline-flex;margin-right:8px;padding:2px 8px;border-radius:999px;background:#f2f6fa;border:1px solid {mg};font-family:"Consolas","SFMono-Regular",monospace;color:{p}}}
176
+ .card-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px}}
177
+ .card{{background:{w};border-radius:14px;box-shadow:var(--shadow-soft);cursor:pointer;transition:box-shadow .2s,transform .15s;overflow:hidden;outline:none;border:1px solid rgba(13,31,60,.06)}}
178
+ .card:hover{{box-shadow:var(--shadow-strong);transform:translateY(-2px)}}
179
+ .card:focus{{box-shadow:0 0 0 3px rgba(26,111,168,.2)}}
180
+ .card.expanded{{box-shadow:var(--shadow-strong)}}
181
  .card.expanded .caret{{transform:rotate(180deg)}}
182
  .caret{{display:block;transition:transform .2s}}
183
+ .faq-block{{display:grid;gap:10px;margin-top:16px}}
184
+ .faq-item{{border-top:1px solid rgba(13,31,60,.08);padding-top:10px}}
185
+ .faq-item summary{{cursor:pointer;font-size:12px;font-weight:800;color:{n};list-style:none}}
186
+ .faq-item summary::-webkit-details-marker{{display:none}}
187
+ .faq-item p{{margin-top:8px;font-size:12px;color:{dg}}}
188
+ .tip-icon{{display:inline-flex;align-items:center;justify-content:center;width:17px;height:17px;border-radius:50%;background:{mg};color:{dg};font-size:10px;font-weight:800;cursor:help;position:relative;flex-shrink:0;vertical-align:middle}}
189
  [data-tooltip]{{position:relative}}
190
+ [data-tooltip]::after{{
191
+ content:attr(data-tooltip);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);
192
+ background:{n};color:#fff;padding:9px 12px;border-radius:10px;font-size:11px;line-height:1.45;white-space:normal;
193
+ max-width:280px;width:max-content;opacity:0;pointer-events:none;transition:opacity .2s;z-index:200;text-align:left;
194
+ font-weight:500;box-shadow:0 10px 28px rgba(0,0,0,.25)
195
+ }}
 
196
  [data-tooltip]:hover::after,[data-tooltip]:focus::after{{opacity:1}}
197
+ .toggle-row{{display:flex;align-items:center;gap:12px;flex-wrap:wrap}}
198
+ .toggle-group{{display:flex;border:1px solid {mg};border-radius:999px;overflow:hidden;width:fit-content;background:{w}}}
199
+ .toggle-btn{{padding:8px 18px;font-size:12px;font-weight:800;color:{dg};background:{w};border:none;cursor:pointer;transition:all .2s}}
 
200
  .toggle-btn:hover{{background:{lg}}}
201
  .toggle-btn.active{{background:{n};color:#fff}}
202
+ .risk-item{{padding:12px 15px;border-left:4px solid {a};margin-bottom:10px;background:{w};border-radius:0 10px 10px 0;font-size:12px;color:{n};line-height:1.5;transition:border-color .2s,box-shadow .2s}}
203
+ .risk-item:hover{{border-left-color:{r};box-shadow:0 2px 8px rgba(0,0,0,.08)}}
204
+ .domain-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;margin-bottom:18px}}
205
+ .domain-card{{display:flex;align-items:center;gap:10px;padding:11px 14px;background:{w};border:1.5px solid {mg};border-radius:12px;transition:border-color .15s,box-shadow .15s;user-select:none}}
 
 
206
  .domain-card:hover{{border-color:{t};box-shadow:0 2px 8px rgba(0,0,0,.1)}}
207
+ .domain-card.domain-active{{border-color:var(--d-color,{t});box-shadow:0 0 0 3px color-mix(in srgb,var(--d-color,{t}) 18%,transparent)}}
208
+ .domain-num{{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;color:#fff;font-size:11px;font-weight:800;flex-shrink:0}}
209
+ .domain-label{{font-size:11px;color:{n};font-weight:700;flex:1;line-height:1.3}}
210
+ .domain-cnt{{font-size:12px;font-weight:800;flex-shrink:0}}
211
+ .airi-tag{{font-size:12px;color:{dg};font-weight:500;margin-left:4px}}
212
+ .airi-kpi{{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-top:14px}}
213
+ .airi-copy{{display:grid;gap:8px;min-width:240px}}
214
+ .metric-inline{{font-size:12px;color:{n}}}
215
+ .airi-table{{width:100%;border-collapse:collapse}}
216
+ .airi-table th{{padding:10px 8px;font-size:11px;text-align:left;color:{dg};background:{lg};font-weight:800;text-transform:uppercase;letter-spacing:.05em}}
217
  .airi-table tr:hover{{background:{lg}}}
218
  .ev-row{{transition:background .15s;cursor:default}}
219
  .ev-row:hover{{background:{lg}}}
220
+ .filter-chip{{display:inline-flex;align-items:center;padding:5px 12px;border-radius:999px;font-size:11px;font-weight:800;cursor:pointer;border:1px solid {mg};margin:0 6px 8px 0;transition:all .15s;background:{w};user-select:none}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  .filter-chip.active{{background:{n};color:#fff;border-color:{n}}}
222
  .filter-chip:hover:not(.active){{background:{lg}}}
223
+ .footer{{font-size:11px;color:{dg};text-align:center;padding:24px 0;border-top:1px solid rgba(13,31,60,.08);margin-top:40px}}
224
+ @media(max-width:1000px){{
225
+ .metric-grid{{grid-template-columns:repeat(3,minmax(0,1fr))}}
226
+ .stage-deck{{grid-template-columns:1fr}}
227
+ .memo-card,.memo-card:nth-child(1),.memo-card:nth-child(2),.memo-card:nth-child(3),.memo-card:nth-child(4){{grid-column:span 12}}
228
+ .integrity-layout,.airi-layout{{grid-template-columns:1fr}}
229
+ .config-pattern{{grid-template-columns:1fr}}
230
+ .config-grid{{grid-template-columns:1fr}}
231
+ }}
232
+ @media(max-width:760px){{
233
+ .hero{{padding:28px 22px;grid-template-columns:1fr}}
234
+ .content{{padding:24px 14px 42px}}
235
+ .nav-link{{padding:12px 12px;font-size:11px}}
236
+ .metric-grid{{grid-template-columns:repeat(2,minmax(0,1fr))}}
237
+ }}
238
+ @media(max-width:520px){{
239
+ .metric-grid{{grid-template-columns:1fr}}
240
+ .s-title{{font-size:21px}}
241
+ .hero h1{{font-size:28px}}
242
+ }}
243
+ """
244
 
245
 
246
  JS = r"""
 
256
 
257
  function _applyAiriFilter() {
258
  document.querySelectorAll('.airi-covered,.airi-gaps').forEach(function(r) {
259
+ var viewOk = r.classList.contains('airi-' + _airiView);
260
  var domainOk = (_airiDomain === 0 || r.dataset.domain == _airiDomain);
261
  r.style.display = (viewOk && domainOk) ? '' : 'none';
262
  });
 
275
  document.querySelectorAll('.domain-card').forEach(function(c) {
276
  var active = (c.dataset.domain == _airiDomain);
277
  c.classList.toggle('domain-active', active);
278
+ c.style.setProperty('--d-color', active ? c.querySelector('.domain-num').style.background : '');
 
279
  });
280
  _applyAiriFilter();
281
  }
 
292
  function updateNav() {
293
  var cur = '';
294
  document.querySelectorAll('section[id]').forEach(function(s) {
295
+ if (window.scrollY >= s.offsetTop - 88) cur = s.id;
296
  });
297
  document.querySelectorAll('.nav-link').forEach(function(l) {
298
  l.classList.toggle('active', l.getAttribute('href') === '#' + cur);