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:
parent
1843ba40c9
commit
06be56fdff
6 changed files with 1004 additions and 23 deletions
377
tools/render.py
Normal file
377
tools/render.py
Normal 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
185
tools/style.css
Normal 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}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue