backdraft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. backdraft/__init__.py +13 -0
  2. backdraft/bind/__init__.py +37 -0
  3. backdraft/bind/binder.py +653 -0
  4. backdraft/bind/cli.py +133 -0
  5. backdraft/bind/evidence.py +232 -0
  6. backdraft/bind/verify/__init__.py +30 -0
  7. backdraft/bind/verify/base.py +70 -0
  8. backdraft/bind/verify/entail.py +156 -0
  9. backdraft/bind/verify/overlap.py +114 -0
  10. backdraft/bind/verify/recompute.py +45 -0
  11. backdraft/bind/verify/value_trace.py +352 -0
  12. backdraft/cli.py +341 -0
  13. backdraft/cli_context.py +152 -0
  14. backdraft/credentials.py +106 -0
  15. backdraft/extract/__init__.py +37 -0
  16. backdraft/extract/base.py +184 -0
  17. backdraft/extract/pdf_text.py +124 -0
  18. backdraft/extract/text.py +44 -0
  19. backdraft/extract/vlm.py +175 -0
  20. backdraft/extract/vlm_settings.py +197 -0
  21. backdraft/extract/xlsx.py +244 -0
  22. backdraft/gate/__init__.py +39 -0
  23. backdraft/gate/cli.py +131 -0
  24. backdraft/gate/reader.py +592 -0
  25. backdraft/gate/searcher.py +123 -0
  26. backdraft/kernel/__init__.py +17 -0
  27. backdraft/kernel/artifact.py +234 -0
  28. backdraft/kernel/chunking.py +162 -0
  29. backdraft/kernel/claims.py +142 -0
  30. backdraft/kernel/errors.py +35 -0
  31. backdraft/kernel/hashing.py +89 -0
  32. backdraft/kernel/model.py +336 -0
  33. backdraft/kernel/tokens.py +291 -0
  34. backdraft/registry/__init__.py +39 -0
  35. backdraft/registry/ledger.py +82 -0
  36. backdraft/registry/schema.sql +90 -0
  37. backdraft/registry/store.py +915 -0
  38. backdraft/render/__init__.py +31 -0
  39. backdraft/render/_text.py +69 -0
  40. backdraft/render/cli.py +107 -0
  41. backdraft/render/footnotes.py +172 -0
  42. backdraft/render/html.py +1211 -0
  43. backdraft/render/markdown.py +313 -0
  44. backdraft/render/placement.py +105 -0
  45. backdraft/render/sidecar.py +164 -0
  46. backdraft-0.2.0.dist-info/METADATA +253 -0
  47. backdraft-0.2.0.dist-info/RECORD +50 -0
  48. backdraft-0.2.0.dist-info/WHEEL +4 -0
  49. backdraft-0.2.0.dist-info/entry_points.txt +2 -0
  50. backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1211 @@
1
+ """The artifact: one HTML file that carries the document, its receipts, and
2
+ its evidence.
3
+
4
+ Designed for two readers in three disclosure layers (DESIGN.md, 2026-07-28):
5
+
6
+ * **The document.** Editorial typography on plain paper; claims wear a faint
7
+ underline and a small numbered mark. **Success is silent** — a fully-resolved
8
+ artifact says nothing about citations on its face; failures earn one plain
9
+ sentence under the title and a wavy underline on their claims.
10
+ * **The footnote.** Click a claim: its reference card opens in the evidence
11
+ rail — the source's words rendered as prose, then the source itself: the
12
+ actual page image, or the cited cell highlighted in a spreadsheet window that
13
+ opens into the full sheet. Multiple citations sit behind a per-source
14
+ selector. No tokens, no hashes, no jargon at this layer.
15
+ * **The record.** A `Record` disclosure per citation: token, sha256, and each
16
+ verifier's verdict in plain words. The machine-readable record is the JSON
17
+ island; `$legend` teaches a cold reader to decode it.
18
+
19
+ The load-bearing constraint is **no network, single file** — enforced by a CSP
20
+ `meta` tag (`default-src 'none'`), not promised. Inline JS is progressive
21
+ enhancement over a CSS-and-anchor baseline: with scripts stripped, claims
22
+ degrade to footnote jumps into the Notes section and every receipt is still
23
+ readable.
24
+
25
+ Evidence comes from `report.evidence` (assembled at bind, carried in the
26
+ sidecar); a report without evidence renders quotes alone. The island embeds the
27
+ sidecar payload minus `evidence.pages[*].data` — the same bytes are already in
28
+ the page as image elements, and `LEGEND["evidence"]` licenses the omission.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import difflib
34
+ import html as html_module
35
+ import json
36
+ import re
37
+ import urllib.parse
38
+ from typing import Any
39
+
40
+ from ..kernel.artifact import FORMAT, sidecar
41
+ from ..kernel.model import BindReport, Citation, CitationStatus, Claim, Verdict
42
+ from . import markdown
43
+ from .placement import Placement, locate
44
+
45
+ __all__ = ["render", "ISLAND_ID", "SHEETS_ISLAND_ID", "STYLESHEET", "FLAME_PATH"]
46
+
47
+ ISLAND_ID = "backdraft-artifact"
48
+ """The id of the `<script type="application/json">` island holding the record."""
49
+
50
+ SHEETS_ISLAND_ID = "bd-sheets"
51
+ """The id of the JSON island holding full cited-sheet values for the sheet view."""
52
+
53
+ FLAME_PATH = (
54
+ "M44 3 C38 8 30 10 26 16 C23.5 20 24.5 24 27 26.5 "
55
+ "C20.5 28 14.5 33 14 41 C13.3 51.5 22 59.5 33 59.5 "
56
+ "C44 59.5 51.5 51.5 51 41.5 C50.6 33.5 45 29 42.5 22.5 "
57
+ "C40.8 18 42.5 9 44 3 Z "
58
+ "M34 34 C38 38.5 39.5 43.5 37.5 47.5 C35 52 28.5 51.5 26.5 47 "
59
+ "C25 43.5 27.5 40 30 38.5 C31.8 37.5 33.2 36 34 34 Z"
60
+ )
61
+ """The backdraft mark: a flame swept backward by returning air. The canonical
62
+ vector is `assets/backdraft-mark.svg`; this is its in-code copy for the favicon
63
+ and the sign-off."""
64
+
65
+ CELL_RE = re.compile(r"^(?P<sheet>[^!]+)!(?P<ref>[A-Z]{1,3}\d+)$")
66
+ PAGE_RE = re.compile(r"^p(?P<page>\d+)(?:\.c\d+)?$")
67
+ IMAGE_TAG_RE = re.compile(r"\[(?:IMAGE|CHART|MAP):[^\]]*\]\s*")
68
+ SUBTITLE_RE = re.compile(r"^\*([^*].*?)\*\s*$")
69
+
70
+ _STATUS_RANK = {
71
+ CitationStatus.RESOLVED: 0,
72
+ CitationStatus.NOT_SHOWN: 1,
73
+ CitationStatus.DRIFTED: 2,
74
+ CitationStatus.UNRESOLVED: 3,
75
+ CitationStatus.MALFORMED: 4,
76
+ }
77
+
78
+ _STATUS_SENTENCE = {
79
+ CitationStatus.UNRESOLVED: (
80
+ "This citation names nothing in the sources; the claim is untraced."
81
+ ),
82
+ CitationStatus.NOT_SHOWN: "The source exists, but the writer was never shown it.",
83
+ CitationStatus.DRIFTED: "The source has changed since this was written.",
84
+ CitationStatus.MALFORMED: "The citation is malformed and could not be checked.",
85
+ }
86
+
87
+
88
+ def _esc(value: Any) -> str:
89
+ return html_module.escape(str(value), quote=True)
90
+
91
+
92
+ # ---- small text helpers -----------------------------------------------------
93
+
94
+
95
+ def split_subtitle(source: str) -> tuple[str, str | None]:
96
+ """An italic line directly under the `# title` is the document's subtitle.
97
+
98
+ Returns (source without that line, subtitle text or None). The subtitle is
99
+ a fill-in slot the author owns; the masthead shows it under the title.
100
+ """
101
+ lines = source.splitlines(keepends=True)
102
+ seen_title = False
103
+ for index, line in enumerate(lines):
104
+ if not line.strip():
105
+ continue
106
+ if line.startswith("# ") and not seen_title:
107
+ seen_title = True
108
+ continue
109
+ if seen_title and (match := SUBTITLE_RE.match(line.strip())):
110
+ return "".join(lines[:index] + lines[index + 1:]), match.group(1)
111
+ break
112
+ return source, None
113
+
114
+
115
+ def fmt_number(raw: str) -> str:
116
+ """Sheet values display formatted; the record keeps the verbatim value."""
117
+ try:
118
+ value = float(raw)
119
+ except (TypeError, ValueError):
120
+ return raw
121
+ if value == int(value) and abs(value) < 1e15:
122
+ return f"{int(value):,}"
123
+ if 0 < abs(value) < 1:
124
+ return f"{value:.4f}"
125
+ return f"{value:,.0f}"
126
+
127
+
128
+ def _is_number(raw: str) -> bool:
129
+ try:
130
+ float(raw)
131
+ return True
132
+ except (TypeError, ValueError):
133
+ return False
134
+
135
+
136
+ def humanize_sheet(slug: str) -> str:
137
+ fixed = {"dy": "DY", "t12": "T12", "ttm": "TTM", "noi": "NOI"}
138
+ return " ".join(
139
+ fixed.get(word, word.capitalize()) for word in slug.replace("-", " ").split()
140
+ )
141
+
142
+
143
+ def source_title(slug: str, docs: dict) -> str:
144
+ """A human name for a source: its filename's stem, humanized.
145
+
146
+ A slug-shaped stem (`t12-summary`, `underwriting-model`) reads as a
147
+ machine identifier where a reader expects a name, so it is title-cased
148
+ through the same fixed-caps table sheet names use. A stem with its own
149
+ casing is somebody's chosen name and passes through untouched.
150
+ """
151
+ entry = docs.get(slug)
152
+ base = (
153
+ str(entry.get("filename", slug)).rsplit(".", 1)[0].replace("_", " ")
154
+ if entry
155
+ else slug
156
+ )
157
+ if re.fullmatch(r"[a-z0-9][a-z0-9 \-]*", base):
158
+ return humanize_sheet(base.replace(" ", "-"))
159
+ return base
160
+
161
+
162
+ def md_html(text: str) -> str:
163
+ """Snippet or page text rendered as markdown for human display."""
164
+ return markdown.to_html(IMAGE_TAG_RE.sub("", text), [])
165
+
166
+
167
+ def table_heavy(text: str) -> bool:
168
+ lines = [line for line in text.splitlines() if line.strip()]
169
+ if not lines:
170
+ return False
171
+ return sum(1 for line in lines if line.lstrip().startswith("|")) > len(lines) * 0.4
172
+
173
+
174
+ def worst_status(claim: Claim) -> CitationStatus:
175
+ statuses = [citation.status for citation in claim.citations]
176
+ if not statuses:
177
+ return CitationStatus.UNRESOLVED
178
+ return max(statuses, key=_STATUS_RANK.__getitem__)
179
+
180
+
181
+ def location(anchor, docs: dict) -> str:
182
+ locator = str(anchor.locator)
183
+ if match := CELL_RE.match(locator):
184
+ return f"{_esc(humanize_sheet(match['sheet']))} &middot; {match['ref']}"
185
+ if match := PAGE_RE.match(locator):
186
+ if docs.get(anchor.slug, {}).get("media_type") == "xlsx":
187
+ return "sheet"
188
+ return f"Page {match['page']}"
189
+ return _esc(locator)
190
+
191
+
192
+ def short_loc(anchor, docs: dict) -> str:
193
+ """Compact label for the source selector: `Page 6`, `D24`, `Sheet`."""
194
+ if anchor is None:
195
+ return "untraced"
196
+ locator = str(anchor.locator)
197
+ if match := CELL_RE.match(locator):
198
+ return match["ref"]
199
+ if match := PAGE_RE.match(locator):
200
+ if docs.get(anchor.slug, {}).get("media_type") == "xlsx":
201
+ return "Sheet"
202
+ return f"Page {match['page']}"
203
+ return locator
204
+
205
+
206
+ # ---- verdict language -------------------------------------------------------
207
+
208
+ _VALUES_FOUND_RE = re.compile(r"^(\d+) value\(s\) found in snippet$")
209
+ _NOT_FOUND_RE = re.compile(r"^not found in snippet: (.+)$")
210
+ _ROUNDED_RE = re.compile(r"^rounded match: (.+)$")
211
+ _TOKENS_RE = re.compile(r"^(\d+)/(\d+) claim tokens in snippet")
212
+ _QUOTED_MISSING_RE = re.compile(r"^quoted span not in snippet: (.+)$")
213
+ _QUOTED_CASE_RE = re.compile(r"^quoted span differs in case: (.+)$")
214
+ _QUOTED_OK_RE = re.compile(r"^(\d+) quoted span\(s\) verbatim in snippet$")
215
+
216
+ _METHOD_LABEL = {
217
+ "value-trace": "Figures",
218
+ "overlap": "Wording",
219
+ "entail": "Reading",
220
+ "recompute": "Math",
221
+ }
222
+
223
+
224
+ def humanize_verdict(verdict: Verdict) -> tuple[str, str]:
225
+ """(label, sentence) for the record layer. Debug strings become prose."""
226
+ label = _METHOD_LABEL.get(verdict.method, verdict.method)
227
+ detail = verdict.detail or ""
228
+ if verdict.method == "value-trace":
229
+ if match := _VALUES_FOUND_RE.match(detail):
230
+ count = int(match.group(1))
231
+ sentence = (
232
+ "The figure in this claim appears in this source."
233
+ if count == 1
234
+ else f"All {count} figures in this claim appear in this source."
235
+ )
236
+ elif match := _NOT_FOUND_RE.match(detail):
237
+ sentence = f"Not found in this source: {match.group(1)}."
238
+ elif match := _ROUNDED_RE.match(detail):
239
+ sentence = f"Matched only after rounding: {match.group(1)}."
240
+ else:
241
+ sentence = detail
242
+ return label, sentence
243
+ if verdict.method == "overlap":
244
+ if match := _TOKENS_RE.match(detail):
245
+ found, total = int(match.group(1)), int(match.group(2))
246
+ sentence = (
247
+ "None of the claim's wording appears in this source."
248
+ if found == 0
249
+ else f"{found} of the claim's {total} words appear in this source."
250
+ )
251
+ elif match := _QUOTED_MISSING_RE.match(detail):
252
+ sentence = f"Quoted phrase not found verbatim in this source: {match.group(1)}."
253
+ elif match := _QUOTED_CASE_RE.match(detail):
254
+ sentence = f"Quoted phrase differs from this source in casing: {match.group(1)}."
255
+ elif match := _QUOTED_OK_RE.match(detail):
256
+ sentence = "Every quoted phrase appears verbatim in this source."
257
+ else:
258
+ sentence = detail
259
+ return label, sentence
260
+ return label, detail or str(verdict.status)
261
+
262
+
263
+ # ---- drift ------------------------------------------------------------------
264
+
265
+ _WORD_RE = re.compile(r"\s+|\S+")
266
+
267
+
268
+ def _word_diff(before: str, after: str) -> tuple[str, str]:
269
+ """Escaped HTML for both snippets, with `<del>`/`<ins>` on what moved."""
270
+ old_words = _WORD_RE.findall(before)
271
+ new_words = _WORD_RE.findall(after)
272
+ matcher = difflib.SequenceMatcher(a=old_words, b=new_words, autojunk=False)
273
+ old: list[str] = []
274
+ new: list[str] = []
275
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
276
+ old_text = html_module.escape("".join(old_words[i1:i2]))
277
+ new_text = html_module.escape("".join(new_words[j1:j2]))
278
+ if tag == "equal":
279
+ old.append(old_text)
280
+ new.append(new_text)
281
+ continue
282
+ if old_text:
283
+ old.append(f"<del>{old_text}</del>")
284
+ if new_text:
285
+ new.append(f"<ins>{new_text}</ins>")
286
+ return "".join(old), "".join(new)
287
+
288
+
289
+ def _drift_block(citation: Citation) -> str:
290
+ if citation.drifted_from is None:
291
+ return ""
292
+ now = citation.anchor.receipt.snippet if citation.anchor else ""
293
+ old_html, new_html = _word_diff(citation.drifted_from, now)
294
+ return (
295
+ '<div class="drift">'
296
+ f'<div class="drift-row"><span>as cited</span><p>{old_html}</p></div>'
297
+ f'<div class="drift-row"><span>now</span><p>{new_html}</p></div>'
298
+ "</div>"
299
+ )
300
+
301
+
302
+ # ---- the renderer -----------------------------------------------------------
303
+
304
+
305
+ def render(source: str, report: BindReport, *, title: str | None = None) -> str:
306
+ """Render the artifact: `source` as a document, `report` as its evidence.
307
+
308
+ `title` overrides the page title, which otherwise comes from the document's
309
+ first heading and falls back to the bound document's filename.
310
+ """
311
+ evidence = report.evidence or {}
312
+ docs: dict = evidence.get("documents", {})
313
+ source, subtitle = split_subtitle(source)
314
+
315
+ placements = locate(source, report.claims)
316
+ placed = [p for p in placements if p.placed]
317
+ orphans = [p for p in placements if not p.placed]
318
+
319
+ page_store: dict[str, dict] = {}
320
+
321
+ spans = []
322
+ for placement in placed:
323
+ flag = "" if worst_status(placement.claim) is CitationStatus.RESOLVED else " flagged"
324
+ text = markdown.inline(placement.claim.text)
325
+ spans.append(markdown.Span(
326
+ start=placement.start, end=placement.end,
327
+ html=(
328
+ f'<a class="claim{flag}" id="claim-{placement.number}" '
329
+ f'href="#note-{placement.number}" data-claim="{placement.number}">'
330
+ f'{text}<sup class="mark">{placement.number}</sup></a>'
331
+ ),
332
+ ))
333
+ body = markdown.to_html(source, spans)
334
+
335
+ cards = "\n".join(_card(p, docs, evidence, page_store) for p in placements)
336
+ notes = "\n".join(_note(p, docs) for p in placements)
337
+
338
+ failures = [
339
+ (p, c)
340
+ for p in placements
341
+ for c in p.claim.citations
342
+ if c.status is not CitationStatus.RESOLVED
343
+ ]
344
+ unmatched = [p for p in placements if p.claim.unmatched or not p.claim.citations]
345
+ n_cites = sum(len(p.claim.citations) for p in placements)
346
+
347
+ heading = title or _title(source, report)
348
+ subtitle_html = f'<p class="subtitle">{_esc(subtitle)}</p>' if subtitle else ""
349
+ alarm_bits: list[str] = []
350
+ if failures:
351
+ alarm_bits.append(
352
+ f"{len(failures)} of {n_cites} citations could not be traced to a source"
353
+ )
354
+ if unmatched:
355
+ alarm_bits.append(f"{len(unmatched)} claims carry no citation")
356
+ if orphans:
357
+ alarm_bits.append(f"{len(orphans)} recorded claims are not in this document")
358
+ alarm_html = (
359
+ f'<p class="alarmline">{_esc("; ".join(alarm_bits))} &mdash; see the notes.</p>'
360
+ if alarm_bits
361
+ else ""
362
+ )
363
+
364
+ return PAGE.format(
365
+ title=_esc(heading),
366
+ favicon=_favicon(),
367
+ css=STYLESHEET,
368
+ js=SCRIPT,
369
+ subtitle=subtitle_html,
370
+ alarm=alarm_html,
371
+ body=body,
372
+ cards=cards,
373
+ notes=notes,
374
+ sources=_sources_index(placements, docs),
375
+ store=_page_store_html(page_store),
376
+ mark_svg=(
377
+ f'<svg class="bd-mark" width="14" height="14" viewBox="0 0 64 64">'
378
+ f'<path fill="#676767" fill-rule="evenodd" d="{FLAME_PATH}"/></svg>'
379
+ ),
380
+ sheets_island_id=SHEETS_ISLAND_ID,
381
+ sheets=_escape_island(json.dumps(evidence.get("sheets", {}), ensure_ascii=False)),
382
+ island_id=ISLAND_ID,
383
+ island=_island(report),
384
+ format=FORMAT,
385
+ )
386
+
387
+
388
+ def _title(source: str, report: BindReport) -> str:
389
+ for line in source.splitlines():
390
+ if line.startswith("# "):
391
+ return line[2:].strip()
392
+ if line.strip():
393
+ break
394
+ return report.doc_path.rsplit("/", 1)[-1]
395
+
396
+
397
+ def _favicon() -> str:
398
+ svg = (
399
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
400
+ '<rect width="64" height="64" rx="13" fill="#FBFAF6"/>'
401
+ '<g transform="translate(32 32) scale(0.82) translate(-32 -32)">'
402
+ f'<path fill="#282828" fill-rule="evenodd" d="{FLAME_PATH}"/></g></svg>'
403
+ )
404
+ return "data:image/svg+xml," + urllib.parse.quote(svg)
405
+
406
+
407
+ def _escape_island(payload: str) -> str:
408
+ """JSON escaped so no byte sequence can close the script element."""
409
+ return payload.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
410
+
411
+
412
+ def _island(report: BindReport) -> str:
413
+ """The record island: the sidecar payload, minus embedded image bytes.
414
+
415
+ `render --to json` carries the full evidence; the island omits
416
+ `evidence.pages[*].data` because the identical bytes are in the document as
417
+ image elements, and `$legend` documents the omission.
418
+ """
419
+ payload = sidecar(report)
420
+ evidence = payload.get("evidence")
421
+ if isinstance(evidence, dict) and isinstance(evidence.get("pages"), dict):
422
+ evidence = dict(evidence)
423
+ evidence["pages"] = {
424
+ key: {k: v for k, v in entry.items() if k != "data"}
425
+ for key, entry in evidence["pages"].items()
426
+ }
427
+ payload = {**payload, "evidence": evidence}
428
+ return _escape_island(json.dumps(payload, indent=2, ensure_ascii=False))
429
+
430
+
431
+ # ---- evidence ---------------------------------------------------------------
432
+
433
+
434
+ def _page_plate(anchor, page: dict, docs: dict, store: dict[str, dict]) -> str:
435
+ match = PAGE_RE.match(str(anchor.locator))
436
+ key = f"ev-{anchor.slug}-p{match['page']}"
437
+ store[key] = page
438
+ caption = f"{_esc(source_title(anchor.slug, docs))}, page {match['page']}"
439
+ return (
440
+ f'<figure class="plate"><img data-ev="{key}" alt="{caption}" '
441
+ f'width="{int(page["width"])}" height="{int(page["height"])}">'
442
+ f"<figcaption><span>{caption}</span>"
443
+ f'<span class="hintcap">click to enlarge</span></figcaption></figure>'
444
+ )
445
+
446
+
447
+ def _page_store_html(store: dict[str, dict]) -> str:
448
+ images = "".join(
449
+ f'<img id="{key}" '
450
+ f'src="data:image/{page["format"]};base64,{page["data"]}" alt="">'
451
+ for key, page in store.items()
452
+ )
453
+ return f'<div id="store" hidden aria-hidden="true">{images}</div>'
454
+
455
+
456
+ def _window_table(window: dict, slug: str) -> str:
457
+ cited = window.get("cited")
458
+ cited_col = re.match(r"[A-Z]+", cited).group() if cited else None
459
+ cited_row = re.search(r"\d+", cited).group() if cited else None
460
+ head = "".join(
461
+ f'<th class="citedcol">{c}</th>' if c == cited_col else f"<th>{c}</th>"
462
+ for c in window["cols"]
463
+ )
464
+ rows = []
465
+ for row in window["rows"]:
466
+ cells = []
467
+ for column in window["cols"]:
468
+ raw = row["cells"].get(column, "")
469
+ ref = f"{column}{row['n']}"
470
+ classes = []
471
+ if ref == cited:
472
+ classes.append("cited")
473
+ if _is_number(raw):
474
+ classes.append("num")
475
+ klass = f' class="{" ".join(classes)}"' if classes else ""
476
+ shown = fmt_number(raw)
477
+ tip = f' title="{_esc(raw)}"' if raw != shown else ""
478
+ cells.append(f"<td{klass}{tip}>{_esc(shown)}</td>")
479
+ row_class = ' class="citedrow"' if str(row["n"]) == cited_row else ""
480
+ rows.append(f'<tr{row_class}><th>{row["n"]}</th>{"".join(cells)}</tr>')
481
+ caption = humanize_sheet(window["sheet"]) + (f", {cited}" if cited else "")
482
+ sheet_key = f"{slug}:{window['sheet']}"
483
+ return (
484
+ f'<figure class="plate grid" data-sheet="{_esc(sheet_key)}" '
485
+ f'data-cited="{_esc(cited or "")}" role="button" tabindex="0" '
486
+ f'aria-label="Open full sheet"><div class="gridwrap"><table>'
487
+ f"<thead><tr><th></th>{head}</tr></thead><tbody>{''.join(rows)}</tbody>"
488
+ f"</table></div><figcaption><span>{_esc(caption)}</span>"
489
+ f'<span class="hintcap">click to open the sheet</span></figcaption></figure>'
490
+ )
491
+
492
+
493
+ def _tabs(source_pane: str, text_pane: str, source_label: str) -> str:
494
+ return (
495
+ '<div class="evidence"><div class="tabs" role="tablist">'
496
+ f'<button class="tab on" data-pane="src">{source_label}</button>'
497
+ '<button class="tab" data-pane="text">Extracted text</button></div>'
498
+ f'<div class="pane on" data-pane="src">{source_pane}</div>'
499
+ f'<div class="pane" data-pane="text">{text_pane}</div></div>'
500
+ )
501
+
502
+
503
+ def _record_block(citation: Citation) -> str:
504
+ rows = [
505
+ f'<div class="rr"><span>token</span><code>{_esc(citation.token)}</code></div>'
506
+ ]
507
+ if citation.anchor is not None:
508
+ rows.append(
509
+ f'<div class="rr"><span>sha256</span>'
510
+ f"<code>{_esc(citation.anchor.receipt.snippet_sha256)}</code></div>"
511
+ )
512
+ if citation.error:
513
+ rows.append(
514
+ f'<div class="rr"><span>error</span><code>{_esc(citation.error)}</code></div>'
515
+ )
516
+ for verdict in citation.verdicts:
517
+ label, sentence = humanize_verdict(verdict)
518
+ rows.append(
519
+ f'<div class="rr verdict v-{verdict.status}"><span>{_esc(label)}</span>'
520
+ f'<code class="vs">{_esc(str(verdict.status))}</code> {_esc(sentence)}</div>'
521
+ )
522
+ return f'<details class="record"><summary>Record</summary>{"".join(rows)}</details>'
523
+
524
+
525
+ def _status_sentence(citation: Citation) -> str:
526
+ sentence = _STATUS_SENTENCE.get(citation.status)
527
+ return f'<p class="alarm">{sentence}</p>' if sentence else ""
528
+
529
+
530
+ def _citation(
531
+ citation: Citation, docs: dict, evidence: dict,
532
+ store: dict[str, dict], *, index: int, on: bool,
533
+ ) -> str:
534
+ wrap = f'<div class="cite{" on" if on else ""}" data-cite="{index}">'
535
+ if citation.anchor is None:
536
+ return f"{wrap}{_status_sentence(citation)}{_record_block(citation)}</div>"
537
+ anchor = citation.anchor
538
+ locator = str(anchor.locator)
539
+ parts = [
540
+ wrap,
541
+ f'<p class="src"><span class="doc">{_esc(source_title(anchor.slug, docs))}</span>'
542
+ f'<span class="loc">{location(anchor, docs)}</span></p>',
543
+ _status_sentence(citation),
544
+ _drift_block(citation),
545
+ ]
546
+
547
+ cell = CELL_RE.match(locator)
548
+ page_m = PAGE_RE.match(locator)
549
+ is_sheet_doc = docs.get(anchor.slug, {}).get("media_type") == "xlsx"
550
+ raw = f'<pre class="rawtext">{_esc(anchor.receipt.snippet[:4000])}</pre>'
551
+ if cell:
552
+ window = evidence.get("windows", {}).get(f"{anchor.slug}:{locator}")
553
+ if window:
554
+ parts.append(_tabs(_window_table(window, anchor.slug), raw, "Cells"))
555
+ else:
556
+ parts.append(raw)
557
+ elif page_m and not is_sheet_doc:
558
+ heavy = table_heavy(anchor.receipt.snippet)
559
+ if not heavy:
560
+ parts.append(
561
+ f'<blockquote class="quote">{md_html(anchor.receipt.snippet)}</blockquote>'
562
+ )
563
+ page = evidence.get("pages", {}).get(f"{anchor.slug}:p{page_m['page']}")
564
+ pagetext = evidence.get("pagetexts", {}).get(f"{anchor.slug}:p{page_m['page']}")
565
+ text_pane = (
566
+ f'<div class="pagetext">{md_html(pagetext)}</div>' if pagetext else raw
567
+ )
568
+ if page:
569
+ parts.append(_tabs(_page_plate(anchor, page, docs, store), text_pane, "Page"))
570
+ elif heavy:
571
+ # No page image and a snippet the quote suppressed: the evidence
572
+ # must still be visible — the page text, or the verbatim snippet.
573
+ parts.append(text_pane)
574
+ elif page_m: # a whole-sheet citation
575
+ window = evidence.get("windows", {}).get(f"{anchor.slug}:p{page_m['page']}")
576
+ if window:
577
+ parts.append(_tabs(_window_table(window, anchor.slug), raw, "Cells"))
578
+ else:
579
+ parts.append(raw)
580
+
581
+ parts.append(_record_block(citation))
582
+ parts.append("</div>")
583
+ return "".join(parts)
584
+
585
+
586
+ _TYPE_LABEL = {"xlsx": "Excel", "pdf": "PDF", "text": "Text"}
587
+
588
+
589
+ def _card(placement: Placement, docs: dict, evidence: dict, store: dict[str, dict]) -> str:
590
+ citations = placement.claim.citations
591
+ selector = ""
592
+ if len(citations) > 1:
593
+ locs = [short_loc(c.anchor, docs) for c in citations]
594
+ buttons = []
595
+ for index, citation in enumerate(citations):
596
+ label = _esc(locs[index])
597
+ if locs.count(locs[index]) > 1 and citation.anchor is not None:
598
+ media = docs.get(citation.anchor.slug, {}).get("media_type", "")
599
+ label = f"{label} &middot; {_TYPE_LABEL.get(media, media)}"
600
+ media = (
601
+ docs.get(citation.anchor.slug, {}).get("media_type", "")
602
+ if citation.anchor
603
+ else ""
604
+ )
605
+ kind = {"xlsx": "excel", "pdf": "pdf"}.get(media, "")
606
+ buttons.append(
607
+ f'<button class="{"on " if index == 0 else ""}{kind}" '
608
+ f'data-cite="{index}">{label}</button>'
609
+ )
610
+ selector = (
611
+ f'<p class="srccount">{len(citations)} sources</p>'
612
+ f'<div class="srcsel">{"".join(buttons)}</div>'
613
+ )
614
+ if citations:
615
+ inner = "".join(
616
+ _citation(c, docs, evidence, store, index=i, on=(i == 0))
617
+ for i, c in enumerate(citations)
618
+ )
619
+ else:
620
+ inner = (
621
+ '<div class="cite on"><p class="alarm">This claim carries no citation '
622
+ "and no evidence.</p></div>"
623
+ )
624
+ return (
625
+ f'<article class="card" id="card-{placement.number}" hidden>'
626
+ f'<header><span class="cardno">Reference {placement.number}</span>'
627
+ f'<button class="close" data-close aria-label="Close">&times;</button></header>'
628
+ f"{selector}{inner}</article>"
629
+ )
630
+
631
+
632
+ def _note(placement: Placement, docs: dict) -> str:
633
+ parts = []
634
+ if placement.claim.unmatched or not placement.claim.citations:
635
+ parts.append(
636
+ '<p class="alarm">This claim carries no citation and no evidence.</p>'
637
+ f'<blockquote class="quote">{_esc(placement.claim.text[:200])}</blockquote>'
638
+ )
639
+ if not placement.placed:
640
+ parts.append(
641
+ '<p class="alarm">This claim is in the record but its words are not '
642
+ "in the document as rendered.</p>"
643
+ f'<blockquote class="quote">{_esc(placement.claim.text[:200])}</blockquote>'
644
+ )
645
+ for citation in placement.claim.citations:
646
+ if citation.anchor is None:
647
+ parts.append(_status_sentence(citation) + _record_block(citation))
648
+ continue
649
+ snippet = citation.anchor.receipt.snippet
650
+ quote_html = ""
651
+ if not table_heavy(snippet):
652
+ short = snippet if len(snippet) <= 360 else snippet[:360].rsplit(" ", 1)[0] + " …"
653
+ short = short.replace("**", "").replace("*", "")
654
+ quote_html = f'<blockquote class="quote">{md_html(short)}</blockquote>'
655
+ parts.append(
656
+ f'<p class="src"><span class="doc">{_esc(source_title(citation.anchor.slug, docs))}</span>'
657
+ f'<span class="loc">{location(citation.anchor, docs)}</span></p>'
658
+ f"{_status_sentence(citation)}{_drift_block(citation)}"
659
+ f"{quote_html}{_record_block(citation)}"
660
+ )
661
+ return (
662
+ f'<li class="note" id="note-{placement.number}">'
663
+ f'<a class="backref" href="#claim-{placement.number}" '
664
+ f'title="Back to the claim">{placement.number}</a>'
665
+ f'<div>{"".join(parts)}</div></li>'
666
+ )
667
+
668
+
669
+ def _sources_index(placements: list[Placement], docs: dict) -> str:
670
+ counts: dict[str, int] = {}
671
+ for placement in placements:
672
+ for citation in placement.claim.citations:
673
+ if citation.anchor is not None:
674
+ counts[citation.anchor.slug] = counts.get(citation.anchor.slug, 0) + 1
675
+ items = []
676
+ for slug, count in sorted(counts.items(), key=lambda item: -item[1]):
677
+ entry = docs.get(slug, {})
678
+ meta = _esc(entry.get("filename", slug))
679
+ items.append(
680
+ f'<li><span class="doc">{_esc(source_title(slug, docs))}</span>'
681
+ f'<span class="filemeta">{meta} &middot; '
682
+ f"{count} citation{'s' if count != 1 else ''}</span></li>"
683
+ )
684
+ return "".join(items)
685
+
686
+
687
+ # ---- the page ---------------------------------------------------------------
688
+
689
+ STYLESHEET = """
690
+ *,*::before,*::after{box-sizing:border-box}
691
+ :root{
692
+ color-scheme:light;
693
+ --paper:#FFFFFF; --ink:#282828; --muted:#676767; --faint:#A3A19A;
694
+ --hover:rgba(40,40,40,.05); --active:rgba(40,40,40,.09);
695
+ --underline:rgba(40,40,40,.16);
696
+ --hairline:#E8E5DD; --hairline-strong:#D6D2C6;
697
+ --notebook:#FBFAF6; --notebook-line:rgba(40,40,40,.05);
698
+ --sel:#1F7244; --sel-soft:#EAF3EE;
699
+ --excel-line:#E3E2DC; --excel-head:#F4F3EE;
700
+ --hl:#F5E6AE;
701
+ --alarm:#A63A2E;
702
+ --serif:'Iowan Old Style','Iowan Old Style BT',Palatino,'Palatino Linotype',Georgia,serif;
703
+ --sans:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Helvetica,sans-serif;
704
+ --mono:ui-monospace,'SF Mono',Menlo,Consolas,monospace;
705
+ --rail-w:calc(50vw - 14px);
706
+ }
707
+ html{-webkit-text-size-adjust:100%}
708
+ body{margin:0;background:var(--notebook);color:var(--ink);
709
+ font-family:var(--serif);font-size:17px;line-height:1.6;
710
+ font-synthesis:none;text-rendering:optimizeLegibility}
711
+ .frame{display:grid;grid-template-columns:minmax(0,1fr) 28px var(--rail-w)}
712
+ .pagecol{grid-column:1;min-width:0;max-width:44rem;width:100%;
713
+ padding:4rem 2.75rem 6rem;margin:0 auto}
714
+ .divider{grid-column:2;cursor:col-resize;touch-action:none}
715
+ .divider::before{content:'';display:block;position:sticky;top:12.5vh;height:75vh;
716
+ width:1px;margin:0 auto;background:var(--hairline-strong);transition:background .15s}
717
+ .divider:hover::before,.divider.dragging::before{background:var(--ink);width:2px}
718
+ .railcol{grid-column:3;min-width:0;
719
+ background:
720
+ repeating-linear-gradient(0deg,var(--notebook-line) 0 1px,transparent 1px 18px),
721
+ repeating-linear-gradient(90deg,var(--notebook-line) 0 1px,transparent 1px 18px),
722
+ var(--notebook)}
723
+ .rail{position:sticky;top:0;max-height:100vh;overflow-y:auto;
724
+ font-family:var(--sans);font-size:.84rem;line-height:1.55;
725
+ padding:2.5rem 2.25rem}
726
+ a{color:inherit}
727
+
728
+ /* ---- masthead ---- */
729
+ .masthead{margin:0 0 3rem;text-align:center}
730
+ .masthead h1{font-size:1.85rem;line-height:1.35;margin:0 0 .55rem;font-weight:600}
731
+ .subtitle{font-family:var(--serif);font-size:.95rem;color:var(--muted);margin:0}
732
+ .alarmline{font-family:var(--sans);font-size:.78rem;color:var(--alarm);
733
+ margin:.6rem 0 0}
734
+
735
+ /* ---- the document ---- */
736
+ .doc h1{display:none}
737
+ .doc h2{font-family:var(--serif);font-size:1.2rem;font-weight:600;
738
+ margin:2.3rem 0 .7rem}
739
+ .doc p{margin:0 0 1.05rem}
740
+ .doc ul,.doc ol{margin:0 0 1.05rem;padding-left:1.4rem}
741
+ .doc blockquote{margin:1.1rem 0;padding:.1rem 0 .1rem 1rem;
742
+ border-left:2px solid var(--hairline);color:var(--muted)}
743
+ .doc pre{font-family:var(--mono);font-size:.78rem;line-height:1.5;overflow-x:auto;
744
+ background:var(--paper);border:1px solid var(--hairline);border-radius:3px;
745
+ padding:.8rem .95rem;margin:0 0 1.05rem}
746
+ .doc code{font-family:var(--mono);font-size:.85em}
747
+ .table-wrap{overflow-x:auto;margin:0 0 1.1rem}
748
+ .doc table{border-collapse:collapse;font-family:var(--sans);font-size:.84rem}
749
+ .doc th,.doc td{padding:.35rem .65rem;border-bottom:1px solid var(--hairline);
750
+ text-align:left}
751
+ .doc .t-right{text-align:right}
752
+ .doc .t-center{text-align:center}
753
+ .bd-image{color:var(--muted);font-style:italic}
754
+ .claim{color:inherit;cursor:pointer;
755
+ text-decoration:underline;text-decoration-color:var(--underline);
756
+ text-decoration-thickness:1px;text-underline-offset:3px;
757
+ transition:background-color .15s,text-decoration-color .15s}
758
+ .claim:hover{background:var(--hover);text-decoration-color:var(--ink)}
759
+ .claim.active{background:var(--active);text-decoration-color:var(--ink)}
760
+ .claim.flagged{text-decoration-style:wavy;text-decoration-color:var(--alarm)}
761
+ .mark{font-family:var(--sans);font-size:.58em;font-weight:600;color:var(--muted);
762
+ margin-left:.14em}
763
+ .claim:hover .mark,.claim.active .mark{color:var(--ink)}
764
+ .claim.flagged .mark{color:var(--alarm)}
765
+
766
+ /* ---- rail resting ---- */
767
+ .resting{background:var(--paper);border:1px solid var(--hairline);border-radius:3px;
768
+ padding:1.2rem 1.35rem;box-shadow:0 1px 3px rgba(40,40,40,.05)}
769
+ .resting h2{font-size:.66rem;letter-spacing:.14em;text-transform:uppercase;
770
+ margin:0 0 .9rem;color:var(--faint);font-weight:600}
771
+ .resting ul{list-style:none;margin:0;padding:0}
772
+ .resting li{margin:.75rem 0}
773
+ .resting .doc{display:block;color:var(--ink);font-weight:600;font-size:.84rem}
774
+ .resting .filemeta{display:block;font-size:.72rem;color:var(--muted);
775
+ overflow-wrap:anywhere;margin-top:.15rem}
776
+ .resting .hint{margin:1.2rem 0 0;font-size:.74rem;color:var(--muted);line-height:1.5}
777
+
778
+ /* ---- the card ---- */
779
+ .card{background:var(--paper);border:1px solid var(--hairline);border-radius:3px;
780
+ box-shadow:0 1px 3px rgba(40,40,40,.05),0 8px 24px rgba(40,40,40,.06);
781
+ padding:1.05rem 1.35rem 1.15rem;animation:rise .16s ease;
782
+ resize:vertical;overflow:auto;min-height:12rem;max-height:82vh}
783
+ @keyframes rise{from{opacity:0;transform:translateY(.35rem)}to{opacity:1;transform:none}}
784
+ .card header{display:flex;align-items:baseline;justify-content:space-between;
785
+ padding:0 0 .2rem}
786
+ .cardno{font-family:var(--sans);font-size:.74rem;font-weight:600;color:var(--ink);
787
+ letter-spacing:.02em}
788
+ .close{background:none;border:0;font-size:1.05rem;line-height:1;color:var(--faint);
789
+ cursor:pointer;padding:.1rem .3rem}
790
+ .close:hover{color:var(--ink)}
791
+
792
+ /* source selector */
793
+ .srccount{font-family:var(--sans);font-size:.68rem;color:var(--faint);
794
+ margin:.1rem 0 0}
795
+ .srcsel{display:flex;gap:1.3rem;border-bottom:1px solid var(--hairline);
796
+ margin:.3rem 0 .95rem}
797
+ .srcsel button{font-family:var(--sans);font-size:.76rem;font-weight:500;
798
+ color:var(--muted);background:none;border:0;padding:.45rem 0;cursor:pointer;
799
+ border-bottom:2px solid transparent;margin-bottom:-1px;letter-spacing:.01em}
800
+ .srcsel button:hover{color:var(--ink)}
801
+ .srcsel button.on{color:var(--ink);font-weight:600;border-bottom-color:var(--ink)}
802
+ .srcsel button.on.excel{border-bottom-color:var(--sel)}
803
+ .srcsel button.on.pdf{border-bottom-color:#9E3B2F}
804
+
805
+ .cite{display:none;padding:.15rem 0}
806
+ .cite.on{display:block}
807
+ .src{margin:.2rem 0 .6rem;display:flex;align-items:baseline;gap:.6rem;flex-wrap:wrap}
808
+ .src .doc{font-weight:600;color:var(--ink);font-size:.88rem}
809
+ .src .loc{font-size:.72rem;color:var(--faint)}
810
+ .alarm{color:var(--alarm);font-size:.78rem;margin:.35rem 0}
811
+
812
+ .quote{margin:.35rem 0 .75rem;padding:.05rem 0 .05rem .9rem;
813
+ border-left:2px solid var(--hairline-strong);font-family:var(--serif);
814
+ font-size:.9rem;line-height:1.55;color:var(--ink);
815
+ max-height:15rem;overflow-y:auto}
816
+ .quote p{margin:0 0 .5rem}.quote p:last-child{margin:0}
817
+ .quote h1,.quote h2,.quote h3,.quote h4{font-family:var(--sans);font-size:.64rem;
818
+ letter-spacing:.1em;text-transform:uppercase;color:var(--faint);
819
+ margin:.6rem 0 .3rem;font-weight:600}
820
+ .quote ul,.quote ol{margin:.2rem 0 .5rem;padding-left:1.2rem}
821
+ .quote strong{font-weight:600}
822
+ .quote .bd-image{display:none}
823
+
824
+ /* drift: the two snippets, word-diffed */
825
+ .drift{margin:.5rem 0 .7rem;font-size:.84rem}
826
+ .drift-row{display:flex;gap:.6rem;margin:.3rem 0}
827
+ .drift-row span{flex:0 0 3.6rem;font-family:var(--sans);font-size:.6rem;
828
+ color:var(--faint);text-transform:uppercase;letter-spacing:.08em;padding-top:.2rem}
829
+ .drift-row p{margin:0;font-family:var(--serif);overflow-wrap:anywhere}
830
+ .drift del{text-decoration:line-through;text-decoration-thickness:1px;
831
+ color:var(--alarm);background:rgba(166,58,46,.08)}
832
+ .drift ins{text-decoration:none;color:var(--sel);background:var(--sel-soft)}
833
+
834
+ /* view toggle */
835
+ .evidence{margin:.4rem 0 .3rem}
836
+ .tabs{display:flex;gap:.9rem;margin-bottom:.55rem}
837
+ .tab{font-family:var(--sans);font-size:.68rem;font-weight:500;color:var(--faint);
838
+ background:none;border:0;padding:.15rem 0;cursor:pointer;
839
+ border-bottom:1px solid transparent}
840
+ .tab:hover{color:var(--ink)}
841
+ .tab.on{color:var(--ink);border-bottom-color:var(--ink)}
842
+ .pane{display:none}
843
+ .pane.on{display:block}
844
+ .pagetext,.rawtext{border:1px solid var(--hairline);
845
+ border-radius:2px;padding:.7rem .85rem;background:var(--paper)}
846
+ .pagetext{font-family:var(--serif);font-size:.86rem;line-height:1.55}
847
+ .pagetext h1,.pagetext h2,.pagetext h3{font-family:var(--sans);font-size:.64rem;
848
+ letter-spacing:.1em;text-transform:uppercase;color:var(--faint);
849
+ margin:.7rem 0 .35rem;font-weight:600}
850
+ .pagetext h1:first-child,.pagetext h2:first-child{margin-top:0}
851
+ .pagetext p{margin:0 0 .5rem}
852
+ .pagetext ul{margin:.2rem 0 .5rem;padding-left:1.2rem}
853
+ .pagetext .table-wrap{overflow-x:auto;margin:.4rem 0}
854
+ .pagetext table{border-collapse:collapse;font-family:var(--sans);font-size:.7rem;
855
+ white-space:nowrap}
856
+ .pagetext th,.pagetext td{border:1px solid var(--hairline);padding:.2rem .5rem;
857
+ text-align:left}
858
+ .pagetext th{background:var(--excel-head);font-weight:600}
859
+ .rawtext{font-family:var(--mono);font-size:.68rem;line-height:1.5;white-space:pre-wrap;
860
+ overflow-wrap:anywhere;color:var(--ink);margin:.4rem 0}
861
+
862
+ /* evidence plates */
863
+ .plate{margin:.1rem 0 .3rem;cursor:zoom-in}
864
+ .plate img{display:block;width:100%;height:auto;border:1px solid var(--hairline);
865
+ border-radius:2px;background:#fff}
866
+ .plate:hover img{border-color:var(--hairline-strong)}
867
+ .plate figcaption{font-family:var(--sans);font-size:.68rem;color:var(--muted);
868
+ margin-top:.4rem;display:flex;justify-content:space-between;gap:.6rem}
869
+ .hintcap{color:var(--ink);opacity:0;transition:opacity .15s;white-space:nowrap}
870
+ .plate:hover .hintcap{opacity:.7}
871
+
872
+ /* the excel experience */
873
+ .grid{cursor:pointer}
874
+ .grid .gridwrap{overflow-x:auto;border:1px solid var(--excel-line);border-radius:2px;
875
+ background:var(--paper)}
876
+ .grid table,.sheettable{border-collapse:separate;border-spacing:0;
877
+ font-family:var(--sans);font-size:.72rem;line-height:1.6;min-width:100%;
878
+ font-variant-numeric:tabular-nums}
879
+ .grid th,.sheettable th{font-weight:500;color:#6B6B66;background:var(--excel-head);
880
+ padding:.1rem .55rem;text-align:center;
881
+ border-bottom:1px solid var(--excel-line);border-right:1px solid var(--excel-line);
882
+ font-size:.66rem}
883
+ .grid td,.sheettable td{padding:.1rem .55rem;
884
+ border-bottom:1px solid var(--excel-line);border-right:1px solid var(--excel-line);
885
+ white-space:nowrap;max-width:14rem;overflow:hidden;text-overflow:ellipsis;
886
+ background:var(--paper);text-align:left}
887
+ .grid td.num,.sheettable td.num{text-align:right}
888
+ .grid td.cited,.sheettable td.cited{background:var(--sel-soft);
889
+ box-shadow:inset 0 0 0 2px var(--sel);font-weight:600}
890
+ .grid tbody th{position:sticky;left:0}
891
+ tr.citedrow th{background:var(--sel-soft);color:var(--sel);font-weight:600}
892
+ th.citedcol{background:var(--sel-soft);color:var(--sel);font-weight:600}
893
+
894
+ /* the record layer */
895
+ .record{margin:.5rem 0 .1rem;font-size:.7rem}
896
+ .record summary{cursor:pointer;color:var(--faint);letter-spacing:.1em;
897
+ text-transform:uppercase;font-size:.6rem;font-weight:600;list-style:none}
898
+ .record summary::before{content:'\\25B8';margin-right:.35rem;font-size:.55rem}
899
+ .record[open] summary::before{content:'\\25BE'}
900
+ .record[open] summary{margin-bottom:.35rem}
901
+ .rr{display:flex;gap:.55rem;margin:.22rem 0;color:var(--muted);overflow-wrap:anywhere}
902
+ .rr span{flex:0 0 4.4rem;text-transform:uppercase;letter-spacing:.08em;font-size:.58rem;
903
+ padding-top:.1rem}
904
+ .rr code{font-family:var(--mono);font-size:.66rem;color:var(--ink)}
905
+ .rr.verdict code{text-transform:uppercase;letter-spacing:.05em}
906
+ .v-fail code{color:var(--alarm)} .v-partial code{color:#96690F}
907
+ .v-skip code{color:var(--faint)}
908
+
909
+ /* ---- end matter ---- */
910
+ .endmatter{margin-top:4rem;border-top:1px solid var(--hairline);padding-top:1.6rem;
911
+ font-family:var(--sans)}
912
+ .endmatter h2{font-size:.66rem;letter-spacing:.14em;text-transform:uppercase;
913
+ color:var(--faint);font-weight:600;margin:0 0 1rem}
914
+ .srclist{list-style:none;margin:0 0 2.2rem;padding:0;font-size:.8rem}
915
+ .srclist li{margin:.55rem 0}
916
+ .srclist .doc{font-weight:600}
917
+ .srclist .filemeta{display:block;color:var(--muted);font-size:.7rem}
918
+ .notes{list-style:none;margin:0;padding:0;font-size:.78rem}
919
+ .note{display:flex;gap:1rem;padding:.9rem 0;border-top:1px solid var(--hairline);
920
+ cursor:pointer}
921
+ .note:hover{background:var(--hover)}
922
+ .note:target{background:var(--hover)}
923
+ .note > div{flex:1;min-width:0}
924
+ .backref{flex:0 0 1.4rem;font-family:var(--mono);font-size:.72rem;
925
+ color:var(--faint);text-decoration:none;text-align:right;padding-top:.15rem}
926
+ .backref:hover{color:var(--ink)}
927
+ .note .quote{font-size:.84rem}
928
+ .colophon{margin-top:2.8rem;color:var(--muted);font-size:.72rem;
929
+ line-height:1.55;display:flex;align-items:center;gap:.45rem}
930
+ .bd-mark{flex:0 0 auto}
931
+
932
+ /* ---- overlays ---- */
933
+ .overlay{position:fixed;inset:0;background:rgba(24,23,20,.85);z-index:90;
934
+ display:none;align-items:center;justify-content:center;padding:1.5rem}
935
+ .overlay.open{display:flex}
936
+ .overlay > img{max-width:100%;max-height:100%;border-radius:2px;cursor:zoom-out;
937
+ background:#fff}
938
+ .sheetbox{background:var(--paper);border-radius:4px;
939
+ width:min(78rem,100%);height:min(48rem,100%);display:flex;flex-direction:column;
940
+ overflow:hidden}
941
+ .sheetbox header{display:flex;align-items:center;justify-content:space-between;
942
+ font-family:var(--sans);font-size:.78rem;font-weight:600;
943
+ padding:.7rem 1rem;border-bottom:1px solid var(--hairline)}
944
+ .sheetbox header .close{font-size:1.2rem}
945
+ .sheetscroll{overflow:auto;flex:1;background:var(--paper)}
946
+ .sheettable thead th{position:sticky;top:0;z-index:2}
947
+ .sheettable tbody th{position:sticky;left:0;z-index:1;min-width:2.6rem}
948
+ .sheettable thead th:first-child{left:0;z-index:3}
949
+ .sheettable td{max-width:18rem}
950
+
951
+ /* ---- responsive / print ---- */
952
+ @media (max-width:1140px){
953
+ .frame{display:block}
954
+ .divider{display:none}
955
+ .pagecol{max-width:44rem;margin:0 auto;padding:2.5rem 1.25rem 4rem}
956
+ .railcol{background:none}
957
+ .rail{position:static;max-height:none;padding:0}
958
+ .resting{display:none}
959
+ .card{position:fixed;left:50%;transform:translateX(-50%);bottom:1rem;z-index:80;
960
+ width:min(30rem,calc(100vw - 2rem));max-height:72vh;overflow-y:auto;resize:none}
961
+ @keyframes rise{from{opacity:0;transform:translate(-50%,.5rem)}
962
+ to{opacity:1;transform:translate(-50%,0)}}
963
+ }
964
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
965
+ @media print{
966
+ .railcol,.divider,.overlay{display:none}
967
+ .frame{display:block}
968
+ .pagecol{max-width:none;padding:0}
969
+ .note{break-inside:avoid}
970
+ .claim{text-decoration:none}
971
+ }
972
+ :focus-visible{outline:2px solid var(--ink);outline-offset:2px}
973
+ """
974
+
975
+ SCRIPT = """
976
+ (function () {
977
+ var rail = document.querySelector('.rail');
978
+ var resting = document.querySelector('.resting');
979
+ var sheetsEl = document.getElementById('bd-sheets');
980
+ var sheets = sheetsEl ? JSON.parse(sheetsEl.textContent || '{}') : {};
981
+ var active = null, activeCard = null;
982
+
983
+ function fmt(raw) {
984
+ if (raw === '' || raw == null) return '';
985
+ var v = Number(raw);
986
+ if (isNaN(v)) return raw;
987
+ if (v === Math.trunc(v) && Math.abs(v) < 1e15) return v.toLocaleString('en-US');
988
+ if (Math.abs(v) < 1) return v.toFixed(4);
989
+ return Math.round(v).toLocaleString('en-US');
990
+ }
991
+ function colName(n) {
992
+ var s = '';
993
+ while (n > 0) { var r = (n - 1) % 26; s = String.fromCharCode(65 + r) + s; n = (n - 1 - r) / 26; }
994
+ return s;
995
+ }
996
+
997
+ function deactivate() {
998
+ if (activeCard) activeCard.hidden = true;
999
+ if (active) active.classList.remove('active');
1000
+ if (resting) resting.style.display = '';
1001
+ active = activeCard = null;
1002
+ }
1003
+ function activate(n, claim) {
1004
+ var card = document.getElementById('card-' + n);
1005
+ if (!card) return;
1006
+ deactivate();
1007
+ if (resting) resting.style.display = 'none';
1008
+ card.querySelectorAll('img[data-ev]').forEach(function (img) {
1009
+ if (!img.src) {
1010
+ var stored = document.getElementById(img.dataset.ev);
1011
+ if (stored) img.src = stored.src;
1012
+ }
1013
+ });
1014
+ card.hidden = false;
1015
+ if (claim) {
1016
+ claim.classList.add('active');
1017
+ active = claim;
1018
+ }
1019
+ activeCard = card;
1020
+ if (window.matchMedia('(min-width: 1141px)').matches) {
1021
+ rail.scrollTop = 0;
1022
+ if (claim) {
1023
+ var r = claim.getBoundingClientRect();
1024
+ if (r.top < 0 || r.bottom > innerHeight) claim.scrollIntoView({block: 'center'});
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ function openSheet(key, cited) {
1030
+ var data = sheets[key];
1031
+ if (!data) return;
1032
+ var box = document.getElementById('sheetoverlay');
1033
+ var scroll = box.querySelector('.sheetscroll');
1034
+ box.querySelector('.sheetname').textContent =
1035
+ data.name.replace(/-/g, ' ').replace(/\\b\\w/g, function (ch) { return ch.toUpperCase(); });
1036
+ var citedCol = cited ? cited.replace(/\\d+$/, '') : null;
1037
+ var citedRow = cited ? cited.replace(/^[A-Z]+/, '') : null;
1038
+ var h = '<table class="sheettable"><thead><tr><th></th>';
1039
+ for (var c = 1; c <= data.ncols; c++) {
1040
+ var cn = colName(c);
1041
+ h += '<th' + (cn === citedCol ? ' class="citedcol"' : '') + '>' + cn + '</th>';
1042
+ }
1043
+ h += '</tr></thead><tbody>';
1044
+ for (var r = 1; r <= data.nrows; r++) {
1045
+ h += '<tr' + (String(r) === citedRow ? ' class="citedrow"' : '') + '><th>' + r + '</th>';
1046
+ for (var c2 = 1; c2 <= data.ncols; c2++) {
1047
+ var raw = data.rows[r - 1][c2 - 1];
1048
+ var ref = colName(c2) + r;
1049
+ var cls = [];
1050
+ if (ref === cited) cls.push('cited');
1051
+ if (raw !== '' && raw != null && isFinite(Number(raw))) cls.push('num');
1052
+ h += '<td' + (cls.length ? ' class="' + cls.join(' ') + '"' : '') +
1053
+ ' title="' + String(raw).replace(/"/g, '&quot;') + '">' + fmt(raw) + '</td>';
1054
+ }
1055
+ h += '</tr>';
1056
+ }
1057
+ h += '</tbody></table>';
1058
+ scroll.innerHTML = h;
1059
+ box.classList.add('open');
1060
+ var hlCell = scroll.querySelector('td.cited');
1061
+ if (hlCell) {
1062
+ scroll.scrollTop = hlCell.offsetTop - scroll.clientHeight / 2;
1063
+ scroll.scrollLeft = hlCell.offsetLeft - scroll.clientWidth / 2;
1064
+ }
1065
+ }
1066
+
1067
+ document.addEventListener('click', function (e) {
1068
+ var claim = e.target.closest('a.claim');
1069
+ if (claim) {
1070
+ e.preventDefault();
1071
+ if (active === claim) deactivate();
1072
+ else activate(claim.dataset.claim, claim);
1073
+ return;
1074
+ }
1075
+ if (e.target.closest('[data-close]')) {
1076
+ var over = e.target.closest('.overlay');
1077
+ if (over) { over.classList.remove('open'); return; }
1078
+ deactivate(); return;
1079
+ }
1080
+ var sel = e.target.closest('.srcsel button');
1081
+ if (sel) {
1082
+ var box = sel.closest('.card');
1083
+ box.querySelectorAll('.srcsel button').forEach(function (b) { b.classList.remove('on'); });
1084
+ box.querySelectorAll('.cite').forEach(function (ct) { ct.classList.remove('on'); });
1085
+ sel.classList.add('on');
1086
+ box.querySelector('.cite[data-cite="' + sel.dataset.cite + '"]').classList.add('on');
1087
+ return;
1088
+ }
1089
+ var tab = e.target.closest('.tab');
1090
+ if (tab) {
1091
+ var evd = tab.closest('.evidence');
1092
+ evd.querySelectorAll('.tab').forEach(function (t) { t.classList.remove('on'); });
1093
+ evd.querySelectorAll('.pane').forEach(function (p) { p.classList.remove('on'); });
1094
+ tab.classList.add('on');
1095
+ evd.querySelector('.pane[data-pane="' + tab.dataset.pane + '"]').classList.add('on');
1096
+ return;
1097
+ }
1098
+ var noteRow = e.target.closest('.note');
1099
+ if (noteRow && !e.target.closest('a') && !e.target.closest('summary')) {
1100
+ var noteN = noteRow.id.replace('note-', '');
1101
+ var noteClaim = document.getElementById('claim-' + noteN);
1102
+ if (noteClaim) {
1103
+ noteClaim.scrollIntoView({block: 'center'});
1104
+ activate(noteN, noteClaim);
1105
+ }
1106
+ return;
1107
+ }
1108
+ var grid = e.target.closest('.grid[data-sheet]');
1109
+ if (grid) { openSheet(grid.dataset.sheet, grid.dataset.cited); return; }
1110
+ var img = e.target.closest('.plate img');
1111
+ if (img && img.src) {
1112
+ var zoom = document.getElementById('zoom');
1113
+ zoom.querySelector('img').src = img.src;
1114
+ zoom.classList.add('open');
1115
+ return;
1116
+ }
1117
+ var overlay = e.target.closest('.overlay');
1118
+ if (overlay && (e.target === overlay || overlay.id === 'zoom')) {
1119
+ overlay.classList.remove('open');
1120
+ }
1121
+ });
1122
+ document.addEventListener('keydown', function (e) {
1123
+ if (e.key !== 'Escape') return;
1124
+ var open = document.querySelector('.overlay.open');
1125
+ if (open) open.classList.remove('open');
1126
+ else deactivate();
1127
+ });
1128
+
1129
+ /* draggable divider */
1130
+ var divider = document.querySelector('.divider');
1131
+ if (divider) {
1132
+ var startX = 0, startW = 0;
1133
+ divider.addEventListener('pointerdown', function (e) {
1134
+ startX = e.clientX;
1135
+ startW = document.querySelector('.railcol').getBoundingClientRect().width;
1136
+ divider.classList.add('dragging');
1137
+ divider.setPointerCapture(e.pointerId);
1138
+ });
1139
+ divider.addEventListener('pointermove', function (e) {
1140
+ if (!divider.classList.contains('dragging')) return;
1141
+ var w = Math.min(Math.max(startW - (e.clientX - startX), 320), innerWidth - 420);
1142
+ document.documentElement.style.setProperty('--rail-w', w + 'px');
1143
+ });
1144
+ ['pointerup', 'pointercancel'].forEach(function (ev) {
1145
+ divider.addEventListener(ev, function () { divider.classList.remove('dragging'); });
1146
+ });
1147
+ }
1148
+ })();
1149
+ """
1150
+
1151
+ PAGE = """<!doctype html>
1152
+ <html lang="en">
1153
+ <head>
1154
+ <meta charset="utf-8">
1155
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1156
+ <meta http-equiv="Content-Security-Policy"
1157
+ content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:;">
1158
+ <meta name="generator" content="{format}">
1159
+ <link rel="icon" href="{favicon}">
1160
+ <title>{title}</title>
1161
+ <style>{css}</style>
1162
+ </head>
1163
+ <body>
1164
+ <div class="frame">
1165
+ <div class="pagecol">
1166
+ <header class="masthead">
1167
+ <h1>{title}</h1>
1168
+ {subtitle}{alarm}
1169
+ </header>
1170
+ <main class="doc">
1171
+ {body}
1172
+ </main>
1173
+ <section class="endmatter">
1174
+ <h2>Sources</h2>
1175
+ <ul class="srclist">{sources}</ul>
1176
+ <h2>Notes</h2>
1177
+ <ol class="notes">
1178
+ {notes}
1179
+ </ol>
1180
+ <p class="colophon">{mark_svg} Generated by Backdraft</p>
1181
+ </section>
1182
+ </div>
1183
+ <div class="divider" role="separator" aria-orientation="vertical"
1184
+ aria-label="Resize the evidence rail" title="Drag to resize"></div>
1185
+ <aside class="railcol">
1186
+ <div class="rail">
1187
+ <div class="resting">
1188
+ <h2>Sources</h2>
1189
+ <ul>{sources}</ul>
1190
+ <p class="hint">Click any numbered claim to see the source behind it.</p>
1191
+ </div>
1192
+ {cards}
1193
+ </div>
1194
+ </aside>
1195
+ </div>
1196
+ <div class="overlay" id="zoom" role="dialog" aria-label="Enlarged page"><img alt=""></div>
1197
+ <div class="overlay" id="sheetoverlay" role="dialog" aria-label="Full sheet">
1198
+ <div class="sheetbox"><header><span class="sheetname"></span>
1199
+ <button class="close" data-close aria-label="Close">&times;</button></header>
1200
+ <div class="sheetscroll"></div></div></div>
1201
+ {store}
1202
+ <script type="application/json" id="{sheets_island_id}">
1203
+ {sheets}
1204
+ </script>
1205
+ <script type="application/json" id="{island_id}">
1206
+ {island}
1207
+ </script>
1208
+ <script>{js}</script>
1209
+ </body>
1210
+ </html>
1211
+ """