Take over the renderer; defer controlled disclosure to a risk service

The renderer and its stylesheet moved in from the-custodian with a make build
target, so T02 generalises something that works rather than starting from
scratch. Publication tooling belongs to the repo that owns publication.

Disclosure is resolved for now: full public is fine in build mode, where there
are no users to expose and no attacker with anything to gain. Recorded as
deferred rather than closed, because it stops being true at production - the
same blast-radius disclosure that a consumer must read becomes a map once real
tenant data exists.

Controlled disclosure is deliberately not this repo's job. Publication is about
permanence and currency; embargo is about severity, remediation and timing, and
building it here would put risk judgement in the repo least qualified to make
it. It likely wants a service of its own - a risk-nexus - with this repo as its
publication surface rather than its brain.

The only cost today is one line in T01: the addressing scheme must not assume
every document is public from birth, so that adding an embargo state later is a
new status rather than a URL migration.

First publication retargeted - the framework relocated to NetKingdom canon and
is now tenancy-posture_v0.1, five axes rather than five planes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-17 15:42:00 +02:00
parent 1843ba40c9
commit 06be56fdff
6 changed files with 1004 additions and 23 deletions

377
tools/render.py Normal file
View file

@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""Render a canon markdown document into a styled, self-contained artifact page.
Single source of truth: the markdown. The page is generated, never hand-edited,
so the two cannot diverge.
Design devices are recognised from conventions already present in the markdown
rather than from extra markup, so the source stays a readable document:
* a table whose first column is `**X0**`/`**X1**`... renders as a level ladder
* a table whose first header cell is `Threat` renders as a threat matrix
* a table with a `Kind` column renders with mechanical/adversarial chips
* a table whose first header cell is `E \\ P` renders as the E x P matrix
* a blockquote renders as a pull quote
* `**Decision N.N...**` at the start of a paragraph renders as a decision
* `## N. Title` headings build the section rail
Stdlib only, per the estate's structure-not-tooling stance: a publishing step
that needs its own toolchain is a publishing step that stops being run.
Usage:
python3 tools/render-artifact.py <source.md> --output <page.html> \\
[--title "Name"] [--subtitle "..."]
"""
from __future__ import annotations
import argparse
import html
import pathlib
import re
import sys
STYLE = pathlib.Path(__file__).parent / "style.css"
INLINE_CODE = re.compile(r"`([^`]+)`")
BOLD = re.compile(r"\*\*([^*]+)\*\*")
EM = re.compile(r"(?<![*\w])\*([^*]+)\*(?!\*)")
LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
HEADING = re.compile(r"^(#{1,4})\s+(.*)$")
SECTION_NO = re.compile(r"^(\d+)\.\s+(.*)$")
LEVEL_CELL = re.compile(r"^\*\*([IAEPR])(\d)\*\*$")
DECISION = re.compile(r"^\*\*(Decision [\d.]+[^*]*)\*\*(.*)$", re.S)
def inline(text: str) -> str:
"""Escape, then apply inline markdown. Order matters: code first."""
slots: list[str] = []
def stash(rendered: str) -> str:
slots.append(rendered)
return f"\x00{len(slots) - 1}\x00"
text = INLINE_CODE.sub(lambda m: stash(f"<code>{html.escape(m.group(1))}</code>"), text)
text = html.escape(text, quote=False)
text = LINK.sub(
lambda m: f'<a href="{html.escape(m.group(2), quote=True)}">{m.group(1)}</a>', text
)
text = BOLD.sub(r"<strong>\1</strong>", text)
text = EM.sub(r"<em>\1</em>", text)
for i, rendered in enumerate(slots):
text = text.replace(f"\x00{i}\x00", rendered)
return text
def split_frontmatter(source: str) -> tuple[dict, str]:
if not source.startswith("---\n"):
return {}, source
end = source.index("\n---\n", 4)
meta = {}
for line in source[4:end].splitlines():
if ":" in line and not line.startswith((" ", "-")):
key, _, value = line.partition(":")
meta[key.strip()] = value.strip().strip('"')
return meta, source[end + 5 :]
def parse_table(lines: list[str], start: int) -> tuple[list[list[str]], int]:
rows, i = [], start
while i < len(lines) and lines[i].lstrip().startswith("|"):
cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
if not all(set(c) <= set("-: ") for c in cells):
rows.append(cells)
i += 1
return rows, i
# --- table renderers -------------------------------------------------------
def render_ladder(rows: list[list[str]]) -> str:
"""A level table becomes a stepped scale. Colour depth encodes strength."""
body = rows[1:]
plane = LEVEL_CELL.match(body[0][0]).group(1)
names = {
"I": "Identity", "A": "Authorization", "E": "Enforcement",
"P": "Placement", "R": "Retention",
}
rungs = []
for cells in body:
match = LEVEL_CELL.match(cells[0])
if not match:
continue
n = int(match.group(2))
text = cells[1] if len(cells) > 1 else ""
rungs.append(
f'<div class="rung r{n}"><span class="code">{plane}{n}</span>'
f'<span class="txt">{inline(text)}</span></div>'
)
while len(rungs) < 5:
rungs.append('<div class="rung na"><span class="code">—</span>'
f'<span class="txt">Ladder ends at {plane}{len(rungs) - 1}.</span></div>')
return (
'<div class="ladder"><div class="pname">'
f'{names.get(plane, plane)}<span>plane {plane}</span></div>'
f'<div class="rungs">{"".join(rungs)}</div></div>'
)
def render_threat(rows: list[list[str]]) -> str:
head = "".join(f"<th>{inline(c)}</th>" for c in rows[0])
body = []
for cells in rows[1:]:
tds = [f"<td>{inline(cells[0])}</td>"]
for c in cells[1:]:
cls = "yes" if "" in c else "no" if "" in c else ""
tds.append(f'<td class="{cls}">{inline(c)}</td>')
body.append(f"<tr>{''.join(tds)}</tr>")
return (f'<div class="scroll"><table class="tm"><thead><tr>{head}</tr></thead>'
f'<tbody>{"".join(body)}</tbody></table></div>')
def render_matrix(rows: list[list[str]]) -> str:
"""`E \\ P` table becomes the two-axis grid. Cells hold pins or markers."""
cols = rows[0][1:]
cells = ['<div class="mgrid">']
for cells_row in rows[1:]:
e = cells_row[0].strip("*")
level = int(e[1]) if len(e) > 1 and e[1].isdigit() else 0
cells.append(f'<div class="rlab">{html.escape(e)}</div>')
for value in cells_row[1:]:
v = value.strip()
if v == "":
cells.append('<div class="mcell void"></div>')
continue
tint = f" tint{level}" if level else ""
pins = ""
for entry in (p.strip() for p in v.split("<br>") if p.strip()):
ghost = " ghost" if entry.startswith("(") else ""
pins += f'<span class="pin{ghost}">{inline(entry.strip("()"))}</span>'
cells.append(f'<div class="mcell{tint}">{pins}</div>')
cells.append('<div class="rlab"></div>')
cells.extend(f'<div class="clab">{html.escape(c)}</div>' for c in cols)
cells.append("</div>")
return (
'<div class="matrix-shell"><div class="ylab">Enforcement →</div>'
+ "".join(cells)
+ "</div>"
'<div class="mnote">'
'<span class="k"><span class="sw"></span>Where a service sits today</span>'
'<span class="k"><span class="sw g"></span>Target or default</span>'
'<span class="k"><span class="sw v"></span>Unreachable at this placement</span>'
"</div>"
)
def render_table(rows: list[list[str]]) -> str:
if not rows:
return ""
header = [c.strip() for c in rows[0]]
first = header[0].lower()
if first.replace(" ", "") in {"e\\p", "e\\p"}:
return render_matrix(rows)
if first == "threat":
return render_threat(rows)
kind_col = header.index("Kind") if "Kind" in header else None
# A level table is a ladder. The evidence table also leads with `Level` but
# carries a `Kind` column, and is a table of artifacts, not of rungs.
if first == "level" and kind_col is None and len(rows) > 1 and LEVEL_CELL.match(rows[1][0]):
return render_ladder(rows)
head = "".join(f"<th>{inline(c)}</th>" for c in header)
body = []
for cells in rows[1:]:
tds = []
for i, c in enumerate(cells):
if i == kind_col:
adv = "adv" if "adversarial" in c.lower() else ""
label = re.sub(r"[*_]", "", c).strip()
tds.append(f'<td><span class="kind {adv}">{html.escape(label)}</span></td>')
else:
tds.append(f"<td>{inline(c)}</td>")
body.append(f"<tr>{''.join(tds)}</tr>")
return (f'<div class="scroll"><table><thead><tr>{head}</tr></thead>'
f'<tbody>{"".join(body)}</tbody></table></div>')
# --- document ---------------------------------------------------------------
def render_body(markdown: str) -> tuple[str, list[tuple[str, str, str]]]:
lines = markdown.splitlines()
out: list[str] = []
rail: list[tuple[str, str, str]] = []
open_section = False
ladders_open = False
i = 0
def close_ladders() -> None:
nonlocal ladders_open
if ladders_open:
out.append("</div></div>")
ladders_open = False
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
i += 1
continue
heading = HEADING.match(stripped)
if heading:
level, text = len(heading.group(1)), heading.group(2)
if level == 1:
i += 1
continue
if level == 2:
close_ladders()
if open_section:
out.append("</section>")
match = SECTION_NO.match(text)
if match:
num, title = match.group(1), match.group(2)
anchor = f"s{num}"
rail.append((anchor, num, title))
out.append(
f'<section id="{anchor}"><h2>'
f'<span class="sn">{int(num):02d}</span>{inline(title)}</h2>'
)
else:
anchor = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
rail.append((anchor, "·", text))
out.append(f'<section id="{anchor}"><h2>{inline(text)}</h2>')
open_section = True
else:
close_ladders()
out.append(f"<h3>{inline(text)}</h3>")
i += 1
continue
if stripped.startswith("|"):
rows, i = parse_table(lines, i)
rendered = render_table(rows)
if 'class="ladder"' in rendered:
if not ladders_open:
out.append('<div class="breakout"><div class="ladders">')
ladders_open = True
out.append(rendered)
else:
close_ladders()
out.append(rendered)
continue
close_ladders()
if stripped.startswith("```"):
block = []
i += 1
while i < len(lines) and not lines[i].strip().startswith("```"):
block.append(lines[i])
i += 1
out.append(f"<pre>{html.escape(chr(10).join(block))}</pre>")
i += 1
continue
if stripped.startswith(">"):
quote = []
while i < len(lines) and lines[i].strip().startswith(">"):
quote.append(lines[i].strip().lstrip(">").strip())
i += 1
out.append(f'<div class="rule-quote"><p>{inline(" ".join(quote))}</p></div>')
continue
if re.match(r"^[-*]\s+", stripped) or re.match(r"^\d+\.\s+", stripped):
ordered = bool(re.match(r"^\d+\.\s+", stripped))
items = []
while i < len(lines):
s = lines[i].strip()
if re.match(r"^[-*]\s+", s) or re.match(r"^\d+\.\s+", s):
items.append(re.sub(r"^([-*]|\d+\.)\s+", "", s))
elif s and lines[i].startswith((" ", "\t")) and items:
items[-1] += " " + s
else:
break
i += 1
tag = "ol" if ordered else "ul"
body = "".join(f"<li>{inline(t)}</li>" for t in items)
out.append(f"<{tag}>{body}</{tag}>")
continue
if set(stripped) <= set("-") and len(stripped) >= 3:
i += 1
continue
para = [stripped]
i += 1
while i < len(lines) and lines[i].strip() and not re.match(
r"^(\||>|```|#{1,4}\s|[-*]\s|\d+\.\s|---)", lines[i].strip()
):
para.append(lines[i].strip())
i += 1
text = " ".join(para)
decision = DECISION.match(text)
if decision:
out.append(
f'<p><span class="dec">{inline(decision.group(1))}</span>'
f"{inline(decision.group(2))}</p>"
)
else:
out.append(f"<p>{inline(text)}</p>")
close_ladders()
if open_section:
out.append("</section>")
return "\n".join(out), rail
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("source", type=pathlib.Path)
ap.add_argument("--output", required=True, type=pathlib.Path)
ap.add_argument("--title", default=None)
ap.add_argument("--subtitle", default="")
args = ap.parse_args()
meta, markdown = split_frontmatter(args.source.read_text())
body, rail = render_body(markdown)
title = args.title or meta.get("title", args.source.stem)
display = title.split(":")[0].strip()
eyebrow = " ".join(
f"<span{' class=\"stat\"' if k == 'status' else ''}>{html.escape(v)}</span>"
for k, v in (
("id", meta.get("id", "")),
("status", f"{meta.get('status', '')} · {meta.get('revision', '')}".strip(" ·")),
("date", meta.get("date", "")),
)
if v
)
rail_html = "".join(
f'<li><a href="#{a}"><span class="n">{n}</span>{html.escape(t)}</a></li>'
for a, n, t in rail
)
page = (
f"<title>{html.escape(display)}</title>\n"
f"<style>\n{STYLE.read_text()}\n</style>\n"
'<div class="wrap"><header>'
f'<div class="eyebrow">{eyebrow}<span>generated from canon — do not edit</span></div>'
f"<h1>{html.escape(display)}</h1>"
+ (f'<p class="sub">{html.escape(args.subtitle)}</p>' if args.subtitle else "")
+ '</header><div class="layout">'
f'<nav class="rail" aria-label="Sections"><ol>{rail_html}</ol></nav>'
f"<main>{body}"
f'<footer><span>{html.escape(meta.get("id", ""))} · '
f'{html.escape(meta.get("revision", ""))} · {html.escape(meta.get("status", ""))}</span>'
"<span>generated from the-custodian/canon</span></footer>"
"</main></div></div>\n"
)
args.output.write_text(page)
print(f"{args.output}: {len(rail)} sections, {len(page)} bytes")
return 0
if __name__ == "__main__":
raise SystemExit(main())

185
tools/style.css Normal file
View file

@ -0,0 +1,185 @@
:root{
--paper:#EDEEF0; --surface:#F6F7F8; --surface-2:#E4E6E9;
--ink:#171D24; --ink-2:#4A5561; --ink-3:#737E8A;
--rule:#D3D7DC; --rule-strong:#B6BCC3;
--brass:#8A6A2E; --brass-soft:#EFE5CD; --brass-line:#C9AE74;
--clay:#8A3A2C; --clay-soft:#F2DFDA;
--l0:#DCE0E2; --l1:#B9C4C7; --l2:#8CA1A6; --l3:#567D84; --l4:#23555E;
--chip-fg:#F6F7F8;
--font-display:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
--font-body:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
--font-mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;
--measure:66ch;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--paper:#12161A; --surface:#191E24; --surface-2:#222831;
--ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E;
--rule:#2A3138; --rule-strong:#3B444D;
--brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426;
--clay:#D08A76; --clay-soft:#3A211B;
--l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD;
--chip-fg:#12161A;
}
}
:root[data-theme="dark"]{
--paper:#12161A; --surface:#191E24; --surface-2:#222831;
--ink:#E6E9EC; --ink-2:#A3ADB7; --ink-3:#78838E;
--rule:#2A3138; --rule-strong:#3B444D;
--brass:#C9A45C; --brass-soft:#33290F; --brass-line:#6B5426;
--clay:#D08A76; --clay-soft:#3A211B;
--l0:#262C32; --l1:#35424A; --l2:#4A626B; --l3:#6A939D; --l4:#97C4CD;
--chip-fg:#12161A;
}
*{box-sizing:border-box}
body{
margin:0; background:var(--paper); color:var(--ink);
font-family:var(--font-body); font-size:17px; line-height:1.62;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1180px;margin:0 auto;padding:0 24px 96px}
.layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:56px;align-items:start}
@media (max-width:960px){.layout{grid-template-columns:1fr;gap:0}.rail{display:none}}
/* ---------- rail ---------- */
.rail{position:sticky;top:28px;padding-top:8px;font-family:var(--font-display);font-size:12px;line-height:1.5}
.rail ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px}
.rail a{color:var(--ink-3);text-decoration:none;display:flex;gap:9px}
.rail a:hover,.rail a:focus-visible{color:var(--brass)}
.rail .n{font-family:var(--font-mono);font-size:10px;color:var(--rule-strong);min-width:16px;padding-top:1px}
.rail .grp{margin-top:14px;font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--rule-strong)}
/* ---------- header ---------- */
header{padding:64px 0 40px;border-bottom:2px solid var(--ink);margin-bottom:44px}
.eyebrow{font-family:var(--font-mono);font-size:11.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:14px;margin-bottom:22px}
.eyebrow .stat{color:var(--clay)}
h1{font-family:var(--font-display);font-weight:800;letter-spacing:-.035em;line-height:.94;font-size:clamp(46px,9vw,92px);margin:0 0 6px;text-wrap:balance}
.sub{font-family:var(--font-display);font-weight:500;font-size:clamp(16px,2.4vw,21px);letter-spacing:-.01em;color:var(--ink-2);margin:0 0 30px;max-width:34ch;line-height:1.3}
.metagrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:20px 28px;border-top:1px solid var(--rule);padding-top:20px}
.metagrid dt{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);margin-bottom:5px}
.metagrid dd{margin:0;font-family:var(--font-display);font-size:13.5px;line-height:1.45;color:var(--ink)}
/* ---------- typography ---------- */
section{margin-bottom:60px;scroll-margin-top:24px}
h2{font-family:var(--font-display);font-weight:750;letter-spacing:-.022em;font-size:clamp(24px,3.4vw,31px);line-height:1.12;margin:0 0 18px;text-wrap:balance;display:flex;gap:14px;align-items:baseline}
h2 .sn{font-family:var(--font-mono);font-size:12px;font-weight:400;color:var(--brass);letter-spacing:.06em;flex:none;padding-top:2px}
h3{font-family:var(--font-display);font-weight:700;font-size:16px;letter-spacing:-.008em;margin:34px 0 10px;color:var(--ink)}
p{margin:0 0 15px;max-width:var(--measure)}
ul,ol{max-width:var(--measure);margin:0 0 15px;padding-left:20px}
li{margin-bottom:7px}
strong{font-weight:600}
em{font-style:italic}
code{font-family:var(--font-mono);font-size:.855em;background:var(--surface-2);padding:1px 5px;border-radius:2px}
a{color:var(--brass)}
.lede{font-size:19px;line-height:1.55;color:var(--ink-2);max-width:60ch}
/* ---------- devices ---------- */
.callout{border-left:3px solid var(--brass);background:var(--brass-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)}
.callout p:last-child{margin-bottom:0}
.callout .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px}
.rule-quote{border-top:2px solid var(--ink);border-bottom:2px solid var(--ink);padding:26px 0;margin:28px 0;max-width:var(--measure)}
.rule-quote p{font-family:var(--font-display);font-weight:600;font-size:19px;line-height:1.38;letter-spacing:-.014em;margin:0;text-wrap:balance}
.hard{border-left:3px solid var(--clay);background:var(--clay-soft);padding:18px 22px;margin:0 0 24px;max-width:var(--measure)}
.hard .lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:.13em;text-transform:uppercase;color:var(--clay);display:block;margin-bottom:8px}
.hard p:last-child{margin-bottom:0}
.dec{font-family:var(--font-mono);font-size:10.5px;letter-spacing:.08em;color:var(--brass);text-transform:uppercase}
.vec{font-family:var(--font-mono);font-size:.9em;font-weight:600;background:var(--surface-2);padding:2px 7px;border-radius:2px;white-space:nowrap;letter-spacing:.04em}
/* ---------- tables ---------- */
.scroll{overflow-x:auto;margin:0 0 24px;-webkit-overflow-scrolling:touch}
table{border-collapse:collapse;width:100%;min-width:520px;font-family:var(--font-display);font-size:13.5px;line-height:1.45}
th{text-align:left;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-3);font-weight:400;padding:0 16px 8px 0;border-bottom:1px solid var(--rule-strong);vertical-align:bottom}
td{padding:11px 16px 11px 0;border-bottom:1px solid var(--rule);vertical-align:top;color:var(--ink-2)}
td:first-child{color:var(--ink);font-weight:600}
tbody tr:last-child td{border-bottom:none}
.lvl{font-family:var(--font-mono);font-weight:600;font-size:12px;letter-spacing:.04em;color:var(--ink)}
/* ---------- ladders ---------- */
.breakout{margin:34px 0 40px}
.bhead{display:flex;justify-content:space-between;align-items:baseline;gap:20px;border-bottom:1px solid var(--rule-strong);padding-bottom:9px;margin-bottom:22px;flex-wrap:wrap}
.bhead h3{margin:0;font-size:13px;letter-spacing:.1em;text-transform:uppercase;font-family:var(--font-mono);font-weight:400;color:var(--ink-3)}
.bhead .note{font-family:var(--font-display);font-size:12.5px;color:var(--ink-3)}
.ladders{display:grid;gap:26px}
.ladder{display:grid;grid-template-columns:126px minmax(0,1fr);gap:18px;align-items:start}
@media (max-width:700px){.ladder{grid-template-columns:1fr;gap:10px}}
.ladder .pname{font-family:var(--font-display);font-weight:700;font-size:14px;letter-spacing:-.01em;padding-top:2px}
.ladder .pname span{display:block;font-family:var(--font-mono);font-size:10px;font-weight:400;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-top:3px}
.rungs{display:grid;gap:3px;grid-template-columns:repeat(5,minmax(0,1fr))}
@media (max-width:700px){.rungs{grid-template-columns:repeat(2,minmax(0,1fr))}}
.rung{padding:9px 10px 11px;background:var(--surface);border-top:4px solid var(--l0);min-width:0}
.rung.r1{border-top-color:var(--l1)} .rung.r2{border-top-color:var(--l2)}
.rung.r3{border-top-color:var(--l3)} .rung.r4{border-top-color:var(--l4)}
.rung .code{font-family:var(--font-mono);font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--ink);display:block;margin-bottom:4px}
.rung .txt{font-family:var(--font-display);font-size:11.5px;line-height:1.34;color:var(--ink-2);display:block}
.rung.na{opacity:.42}
/* ---------- matrix ---------- */
.matrix-shell{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:stretch;margin-bottom:14px}
.ylab{writing-mode:vertical-rl;transform:rotate(180deg);font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-3);text-align:center;padding-bottom:22px}
.mgrid{display:grid;grid-template-columns:34px repeat(5,minmax(0,1fr));gap:3px}
.mcell{background:var(--surface);min-height:60px;padding:6px;display:flex;flex-direction:column;justify-content:flex-end;gap:4px;min-width:0}
.mcell.tint1{background:color-mix(in srgb,var(--l1) 26%,var(--surface))}
.mcell.tint2{background:color-mix(in srgb,var(--l2) 26%,var(--surface))}
.mcell.tint3{background:color-mix(in srgb,var(--l3) 24%,var(--surface))}
.mcell.tint4{background:color-mix(in srgb,var(--l4) 22%,var(--surface))}
.mcell.void{background:repeating-linear-gradient(135deg,transparent,transparent 5px,var(--rule) 5px,var(--rule) 6px);opacity:.55}
.rlab,.clab{font-family:var(--font-mono);font-size:10px;font-weight:600;letter-spacing:.05em;color:var(--ink-3);display:flex;align-items:center;justify-content:center}
.rlab{min-height:60px}
.clab{padding-top:7px;min-height:22px}
.pin{font-family:var(--font-mono);font-size:9.5px;font-weight:600;letter-spacing:.02em;background:var(--ink);color:var(--paper);padding:2px 5px;border-radius:2px;line-height:1.3;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pin.ghost{background:transparent;color:var(--ink-2);border:1px dashed var(--rule-strong)}
.mnote{display:flex;gap:22px;flex-wrap:wrap;font-family:var(--font-display);font-size:12px;color:var(--ink-3);padding-top:6px}
.mnote .k{display:flex;align-items:center;gap:7px}
.sw{width:13px;height:13px;flex:none;background:var(--ink)}
.sw.g{background:transparent;border:1px dashed var(--rule-strong)}
.sw.v{background:repeating-linear-gradient(135deg,transparent,transparent 4px,var(--rule) 4px,var(--rule) 5px);border:1px solid var(--rule)}
@media (max-width:640px){
.mgrid{grid-template-columns:28px repeat(5,minmax(0,1fr))}
.mcell{min-height:52px;padding:4px}
.pin{font-size:8px;padding:1px 3px}
.rlab{min-height:52px}
}
/* ---------- methodology ---------- */
.verbs{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule)}
.verb{background:var(--surface);padding:18px 18px 20px}
.verb h4{font-family:var(--font-display);font-weight:750;font-size:15px;margin:0 0 7px;letter-spacing:-.01em}
.verb p{font-family:var(--font-display);font-size:12.5px;line-height:1.46;color:var(--ink-2);margin:0;max-width:none}
.verb .step{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.13em;color:var(--brass);display:block;margin-bottom:9px}
/* ---------- questions ---------- */
.qs{display:flex;flex-direction:column;gap:0;border-top:1px solid var(--rule-strong)}
.q{display:grid;grid-template-columns:34px minmax(0,1fr) 170px;gap:18px;padding:16px 0;border-bottom:1px solid var(--rule);align-items:start}
@media (max-width:760px){.q{grid-template-columns:28px minmax(0,1fr);gap:12px}.q .owner{grid-column:2}}
.q .qn{font-family:var(--font-mono);font-size:11px;color:var(--brass);padding-top:3px}
.q .qt{font-family:var(--font-display);font-size:14px;line-height:1.48;color:var(--ink-2)}
.q .qt b{color:var(--ink);font-weight:700;display:block;margin-bottom:2px;font-size:14.5px}
.owner{font-family:var(--font-mono);font-size:10px;letter-spacing:.05em;color:var(--ink-3);padding-top:4px}
.owner .tag{display:inline-block;border:1px solid var(--rule-strong);padding:2px 7px;border-radius:2px}
.owner .tag.need{border-color:var(--clay);color:var(--clay)}
/* ---------- misc ---------- */
.numbers{font-family:var(--font-mono);font-size:12.5px;line-height:1.85;background:var(--surface);border-left:3px solid var(--l3);padding:16px 20px;margin:0 0 22px;overflow-x:auto;max-width:var(--measure)}
.numbers .v{color:var(--ink);font-weight:600}
.numbers .k{color:var(--ink-3)}
pre{font-family:var(--font-mono);font-size:12.5px;line-height:1.68;background:var(--surface);border-left:3px solid var(--rule-strong);padding:16px 20px;overflow-x:auto;margin:0 0 22px;max-width:var(--measure);color:var(--ink-2)}
.alt{border-bottom:1px solid var(--rule);padding:14px 0;max-width:var(--measure)}
.alt:last-of-type{border-bottom:none}
.alt b{font-family:var(--font-display);font-size:14px;display:block;margin-bottom:3px}
.alt p{font-size:14.5px;margin:0;color:var(--ink-2)}
.alt .verdict{font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--clay)}
footer{border-top:2px solid var(--ink);margin-top:20px;padding-top:22px;font-family:var(--font-mono);font-size:11px;letter-spacing:.06em;color:var(--ink-3);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
.tm td,.tm th{text-align:center}
.tm td:first-child,.tm th:first-child{text-align:left}
.yes{color:var(--l4);font-weight:700}
.no{color:var(--clay);font-weight:700}
.kind{font-family:var(--font-mono);font-size:9px;letter-spacing:.09em;text-transform:uppercase;padding:2px 6px;border-radius:2px;white-space:nowrap;border:1px solid var(--rule-strong);color:var(--ink-3)}
.kind.adv{border-color:var(--clay);color:var(--clay)}
.routes{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:2px;background:var(--rule);border:1px solid var(--rule);margin:0 0 22px}
.route{background:var(--surface);padding:16px 18px}
.route h4{font-family:var(--font-display);font-weight:750;font-size:14px;margin:0 0 6px}
.route p{font-family:var(--font-display);font-size:12.5px;line-height:1.45;color:var(--ink-2);margin:0;max-width:none}
.route .tag{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--brass);display:block;margin-bottom:8px}
a:focus-visible,.rail a:focus-visible{outline:2px solid var(--brass);outline-offset:3px}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}