diff --git a/backend/app/report_export/build.py b/backend/app/report_export/build.py
index cf353fc6..7cbbb079 100644
--- a/backend/app/report_export/build.py
+++ b/backend/app/report_export/build.py
@@ -4,7 +4,9 @@
执行脚本后,带出处的数字、无证据的数字、可疑的表名字段名都点得开——面板里是网页上证据面板同一个拼法的
出处链(api/evidence._chain):指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、
输入步骤(值取自哪个节点、哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查),
-以及运行输入、表名字段名、引文各自的步骤。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。
+以及运行输入、表名字段名、引文各自的步骤;分析结论(判定语、具名的数、结果表的格)点开是判定 → 方法 → 计算 → 输入 →
+复算 → 封存。封存状态照网页上的样子逐项标明(已封存 / 工件哈希核对)。报告文档带着系统写的「分析范围」
+(analysis_scope)时,正文后面另渲染这一段,编号点得开同一条出处链;没有这个字段的文档一个字都不多。
这份文件不是核对件:口径、出处和封存状态都是导出那一刻系统里的记录,拿去和系统对照要用封存哈希
(可验证报告,app/bundle)。页面页脚写明这一点。
@@ -77,7 +79,7 @@ _STATE_CLASS = {
"deterministic": "ok", "none": "na", "suspect": "bad", "unverified": "bad",
"unjudged": "na", "candidate": "na",
}
-_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文"}
+_KIND_LABEL = {"number": "数字", "value": "取值", "entity": "名称", "quote": "引文", "analysis": "判定"}
def _esc(text: Any) -> str:
@@ -293,6 +295,61 @@ def render_body(doc: Mapping[str, Any]) -> str:
return f'
{"".join(_render_block(b) for b in blocks)}
'
+#: 「分析范围」各组的叫法(00-总方案 §3.6),按这个顺序写
+SCOPE_TITLES = (("phenomena", "现象"), ("decomposed", "拆过"), ("excluded", "排除"), ("refused", "算不了"))
+SCOPE_NOTE = "这一段由系统按这次运行的分析结论写成,写作者改不了;点开编号看判定标准、计算、输入和复算。"
+#: 分析范围里一个编号的片段键(api/evidence.SCOPE_REF 同一个写法):点开时按它找出处链
+_SCOPE_REF = "scope:{alias}"
+
+
+def _scope_item(item: Mapping[str, Any], *, depth: int = 0) -> str:
+ """分析范围里的一条:编号(按钮,点开出处链)、维度、下钻自哪一条、判定语或算不了的原因。"""
+ alias = str(item.get("alias") or "")
+ title = f"分析结论 {_esc(alias)}:点开查看出处"
+ button = (f'") if alias else ""
+ dims = [str(d) for d in item.get("dims") or [] if d]
+ bits = ([f"按{'、'.join(dims)}"] if dims else []) + ([f"下钻自 {item['parent']}"] if item.get("parent") else [])
+ lead = f"({','.join(_esc(b) for b in bits)})" if bits else ""
+ said = item.get("reason") if item.get("reason") and not item.get("phrase") else item.get("phrase")
+ margin = f' style="margin-left:{depth * 14}px"' if depth else ""
+ return f"{button}{lead}{':' if said else ''}{_esc(said or '')}"
+
+
+def render_scope(doc: Mapping[str, Any]) -> str:
+ """报告文档里系统写的「分析范围」(analysis.contract.ScopeBlock):现象、拆过(下钻按链缩进)、排除、算不了、没拆、
+ 检验了几次。文档没有这个字段(老文档、这次没有分析结论、报告节点关了这一段)时是空串,页面一个字都不多。"""
+ scope = doc.get("analysis_scope")
+ if not isinstance(scope, Mapping):
+ return ""
+ rows: list[str] = []
+ for key, title in SCOPE_TITLES:
+ items = [i for i in scope.get(key) or [] if isinstance(i, Mapping)]
+ if not items:
+ continue
+ depth: dict[str, int] = {}
+ lines = []
+ for item in items:
+ level = depth.get(str(item.get("parent") or ""), -1) + 1 if item.get("parent") else 0
+ depth[str(item.get("alias") or "")] = level
+ lines.append(_scope_item(item, depth=level))
+ rows.append(f"{title}")
+ missing = scope.get("not_decomposed")
+ if isinstance(missing, list):
+ names = "、".join(_esc(n) for n in missing if n)
+ rows.append(f"没拆{names or '候选维度都拆过了'}")
+ elif "not_decomposed" in scope:
+ rows.append(f"没拆{_esc(scope.get('note') or '候选维度核对不了')}")
+ tests = scope.get("tests")
+ if isinstance(tests, int) and not isinstance(tests, bool) and tests > 0:
+ rows.append(f"检验这次做了 {tests} 次差异检验")
+ if not rows:
+ return ""
+ return (f'分析范围
'
+ f'{SCOPE_NOTE}
{"".join(rows)}
')
+
+
# --------------------------------------------------------------------------
# 数据块和页面
# --------------------------------------------------------------------------
@@ -390,7 +447,7 @@ def render_html(data: Mapping[str, Any], doc: Mapping[str, Any]) -> str:
"NAME": _esc(name),
"SEAL": seal_text,
"RUN": _esc(str(meta.get("run_id") or "")),
- "BODY": render_body(doc),
+ "BODY": render_body(doc) + render_scope(doc),
"CSP": f"'sha256-{digest}'",
"DATA": _json_text(data),
"SCRIPT": script,
diff --git a/backend/app/report_export/runtime.js b/backend/app/report_export/runtime.js
index f88755e7..2f5ca262 100644
--- a/backend/app/report_export/runtime.js
+++ b/backend/app/report_export/runtime.js
@@ -3,9 +3,10 @@
* 原生 JS,无依赖,不连网。正文是服务端按报告文档渲染好的 HTML,这里只做三件事:
* ① 填页面头部(报告节点、文档工件、封存状态、运行信息);
* ② 给带状态的片段接上点击和键盘(事件委托在正文容器上),点开右侧的出处面板;
- * ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算)、
+ * ③ 按数据块里的出处链渲染面板:指标步骤(口径卡、口径与版本、原式、代入式、输入、复算、双盲复算、可比性)、
* 输入步骤(值、来自哪个节点哪次查询的哪一格)、查询步骤(SQL、被引用的行、遮罩列、SQL 检查)、
- * 运行输入、表名字段名、引文,和网页上的证据面板(api/evidence 的片段接口)同一个拼法。
+ * 运行输入、表名字段名、引文,分析结论的六步(判定、方法、计算、输入、复算、封存),和网页上的证据面板
+ * (api/evidence 的片段接口)同一个拼法。正文后面系统写的「分析范围」里的编号同样点得开(data-seg 是 scope:A1)。
* 链里的每一步带着导出那一刻的封存与哈希状态,照实标明;这份文件不是核对件(页脚写明)。
* 来自数据的文字一律经 textContent 写进页面,不拼 HTML。
*/
@@ -20,7 +21,7 @@
deterministic: '有出处', none: '无证据', suspect: '疑似不存在的名称', unverified: '无法核实',
unjudged: '未裁判', candidate: '猜测的来源',
},
- kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文' },
+ kindLabel: { number: '数字', value: '取值', entity: '名称', quote: '引文', analysis: '判定' },
segTitle: (kind, text) => `${kind}「${text}」`,
metaRun: '运行 ID', metaClass: '运行类别', metaStatus: '运行状态', metaReport: '报告节点',
metaStarted: '开始', metaFinished: '结束', metaExported: '导出时间',
@@ -53,6 +54,7 @@
truncated: '查询结果已截断,该字段只含取回的部分行',
// 查询步骤
query: '查询', dSql: 'SQL', dSourceName: '数据源', dTool: '工具', dRows: '被引用的行', dAllRows: (n) => `共 ${n} 行`,
+ dTotalRows: '行数',
dColumnsMasked: '遮罩列', dChecks: 'SQL 检查', windowCut: '被引用的行前后行数太多,只列出其中一部分',
truncatedRows: '查询结果被截断,取回的行不完整',
merge: '合并查询', mergeInputs: '合并的输入', mergeTraced: '被引用格的来历', mergeWarnings: '执行时的警告',
@@ -76,6 +78,23 @@
ledgerRelated: '相关口径', ledgerReasoning: '这次的推理:没有相关的已发布口径', ledgerUnknown: '没有比对',
dBinding: '参数', dDiffers: '差在哪', dHow: '怎么认的', dColumnOf: '对应口径的列',
unnamed: '已删除的工作流',
+ // 口径卡指标上的可比性(跨两份同形查询算的环比、同比)
+ dComparability: '两期可比性', comparabilityLabel: { comparable: '两期可比', conditional: '两期有条件可比', incomparable: '两期不可比' },
+ // 分析结论:判定 → 方法 → 计算 → 输入 → 复算 → 封存
+ analysis: '分析结论', dPhrase: '判定语', dCriterion: '判定标准', dSubjects: '点名的段',
+ dDirection: '方向', directionLabel: { up: '上升', down: '下降' }, dCaveats: '附带说明', dReason: '算不了的原因',
+ dCitedPart: '这一处引用的是', citedPhrase: '判定语', citedValue: (k) => `具名的数「${k}」`,
+ citedCell: (r, c) => `结果表第 ${r + 1} 行「${c}」`, dWho: '谁做的', originLabel: { card: '分析卡(模板定好的分析)', agent: 'Agent 现场做的分析' },
+ dHypothesis: 'Agent 写的假设', refused: '这次分析没有做成', renderOk: '与按结论渲染的结果一致', renderBad: '与按结论渲染的结果不一致',
+ method: '方法', dMethod: '方法与版本', dParams: '参数', dSettings: '实际用的阈值', dProbes: '现查的表范围',
+ probeBasis: { live: '现查', profile: '剖析', observed: '取值观察' },
+ calc: '计算', dValues: '具名的数', dFormulas: '关键数的代入式', dResultTable: '结果表',
+ resultRows: (shown, total) => `列出 ${shown} 行,共 ${total} 行`,
+ analysisInput: '输入', dRole: '角色', dRef: '调用里写的名字', dColumnsUsed: '用到的列', dRowsUsed: '用到的行',
+ allRows: '全部行', dHeadline: '总数那一格', dClosure: '闭合核对',
+ closureOk: (h, t) => `总数 ${h} 与各段之和 ${t} 闭合`, closureBad: (h, t) => `总数 ${h} 与各段之和 ${t} 对不上`,
+ dParent: '下钻自', recompute: '复算', seal: '封存', inputOfAnalysis: (alias) => `分析结论 ${alias} 的输入`,
+ inLedger: '封存范围内的台账记过这份分析结论', notInLedger: '封存范围内的台账里没有这份分析结论',
}
const MASKED = '已遮罩'
@@ -211,6 +230,16 @@
: T.originUnknown(String(u.path || NONE)))).join(';')
: T.originTraced] : null,
])))
+ // 可比性:口径卡执行时自动做的可比性检查(跨两份同形查询算的环比、同比),做过才有
+ if (isObj(step.comparability)) {
+ const c = step.comparability
+ const why = Array.isArray(c.reasons) && c.reasons.length ? `:${c.reasons.join(';')}` : ''
+ body.push(h('div', { style: 'margin-top:8px' }, kv([
+ [T.dComparability, `${T.comparabilityLabel[c.verdict] || c.phrase || NONE}${c.verdict === 'comparable' ? '' : why}`],
+ c.criterion ? [T.dCriterion, String(c.criterion)] : null,
+ c.artifact ? [T.dArtifact, h('code', {}, short(c.artifact))] : null,
+ ])))
+ }
// 双盲复算(期 4):复核过这个指标才有
if (isObj(step.dual_check)) {
const d = step.dual_check
@@ -254,7 +283,7 @@
step.tool ? [T.dTool, h('code', {}, String(step.tool))] : null,
step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
step.alias ? [T.dQuery, String(step.alias)] : null,
- step.total_rows != null ? [T.dAllRows, String(step.total_rows)] : null,
+ step.total_rows != null ? [T.dTotalRows, T.dAllRows(step.total_rows)] : null,
]
body.push(kv(rows.filter(Boolean)))
if (typeof step.sql === 'string' && step.sql) body.push(h('p', { class: 'sub' }, T.dSql), h('pre', { class: 'sql' }, step.sql))
@@ -314,7 +343,7 @@
if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
if (bits.length) body.push(h('div', { class: 'chips' }, ...bits))
return h('section', { class: 'part', 'data-step': 'query' },
- h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}`),
+ h('h3', {}, `${T.query}${step.alias ? ` ${step.alias}` : ''}${step.input_of ? ` · ${T.inputOfAnalysis(step.input_of)}` : ''}`),
h('div', { class: 'part-body' }, ...body))
}
@@ -397,9 +426,142 @@
h('div', { class: 'part-body' }, ...body))
}
+ /** 完整性徽标:已封存、哈希核对失败(每一步都可带) */
+ function integrity(step) {
+ const bits = []
+ if ('sealed' in step) bits.push(step.sealed ? badge('ok', T.sealedOk) : badge('na', T.sealedNo))
+ if (step.hash_ok === false) bits.push(badge('bad', T.hashBad))
+ return bits.length ? h('div', { class: 'chips' }, ...bits) : null
+ }
+
+ const muted = (text) => h('p', { class: 'muted', style: 'font-size:12px;margin:6px 0 0' }, String(text))
+ const waiting = (text) => h('p', { style: 'color: var(--wait); margin: 8px 0 0' }, String(text))
+
+ /** 一张小表:表头 heads,行 rows(每行一组格),高亮的格(行号:列 id)和列 id 对上 */
+ function smallTable(heads, ids, rows, index, hits) {
+ const hit = new Set((hits || []).map((c) => `${c[0]}:${c[1]}`))
+ return h('div', { class: 'table-wrap' }, h('table', {},
+ h('thead', {}, h('tr', {}, heads.map((c) => h('th', {}, String(c))))),
+ h('tbody', {}, rows.map((row, i) => h('tr', {}, (Array.isArray(row) ? row : ids.map((c) => own(row, c))).map((v, j) => h('td', {
+ class: [hit.has(`${index[i]}:${ids[j]}`) ? 'hit' : '', typeof v === 'number' ? 'n' : ''].filter(Boolean).join(' ') || null,
+ }, v == null ? NONE : textOf(v))))))))
+ }
+
+ /** 分析结论 · 判定:判定语、判定标准、判定码和点名的段、附带说明、算不了的原因、这一处引用的是哪一项、谁做的、Agent 写的假设 */
+ function analysisPart(step) {
+ const body = []
+ const verdict = isObj(step.verdict) ? step.verdict : null
+ const cited = isObj(step.cited) ? step.cited : {}
+ const citedText = cited.what === 'value' ? T.citedValue(cited.key) : cited.what === 'cell'
+ ? T.citedCell(Number(cited.row) || 0, String(cited.column ?? '')) : T.citedPhrase
+ body.push(kv([
+ [T.dPhrase, String(step.phrase || NONE)],
+ step.criterion ? [T.dCriterion, String(step.criterion)] : null,
+ verdict && Array.isArray(verdict.subjects) && verdict.subjects.length
+ ? [T.dSubjects, verdict.subjects.map((s) => (Array.isArray(s) ? s.join(' · ') : String(s))).join('、')] : null,
+ verdict && verdict.direction ? [T.dDirection, T.directionLabel[verdict.direction] || String(verdict.direction)] : null,
+ [T.dCitedPart, citedText],
+ [T.dWho, `${T.originLabel[step.origin] || NONE}${step.node_id ? ` · ${step.label || nodeLabel(step.node_id)}` : ''}`],
+ ]))
+ if (step.status === 'refused') body.push(waiting(`${T.refused}${step.reason ? `:${step.reason}` : ''}`))
+ if (Array.isArray(step.caveats) && step.caveats.length) {
+ body.push(h('p', { class: 'sub' }, T.dCaveats),
+ h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' }, step.caveats.map((c) => h('li', {}, String(c.text || '')))))
+ }
+ if (step.hypothesis) {
+ body.push(h('p', { class: 'sub' }, T.dHypothesis), h('p', { style: 'margin:4px 0 0;font-size:13px' }, String(step.hypothesis)))
+ if (step.hypothesis_note) body.push(muted(step.hypothesis_note))
+ }
+ if (step.note) body.push(waiting(step.note))
+ if (typeof step.render_ok === 'boolean') body.push(muted(step.render_ok ? T.renderOk : T.renderBad))
+ return h('section', { class: 'part', 'data-step': 'analysis' },
+ h('h3', {}, `${T.analysis} ${step.alias || ''} · ${step.method_label || NONE}`), h('div', { class: 'part-body' }, ...body))
+ }
+
+ /** 分析结论 · 方法:方法与版本、参数、实际用的阈值、现查的表范围 */
+ function analysisMethodPart(step) {
+ const body = [kv([[T.dMethod, `${step.method_label || NONE} · ${step.method || NONE}`]])]
+ if (isObj(step.params) && Object.keys(step.params).length) {
+ body.push(h('p', { class: 'sub' }, T.dParams), h('pre', { class: 'sql' }, JSON.stringify(step.params, null, 2)))
+ }
+ if (Array.isArray(step.settings) && step.settings.length) {
+ body.push(h('p', { class: 'sub' }, T.dSettings), kv(step.settings.map((x) => [String(x.label || x.key), textOf(x.value)])))
+ }
+ if (Array.isArray(step.probes) && step.probes.length) {
+ body.push(h('p', { class: 'sub' }, T.dProbes), h('ul', { style: 'margin:4px 0 0;padding-left:1.2em;font-size:13px' },
+ step.probes.map((p) => h('li', {}, `${p.table || NONE}.${p.column || NONE}:${p.min || NONE} 至 ${p.max || NONE}`
+ + `(${T.probeBasis[p.basis] || p.basis || NONE}${p.at ? ` · ${p.at}` : ''})`))))
+ }
+ return h('section', { class: 'part', 'data-step': 'analysis_method' }, h('h3', {}, T.method), h('div', { class: 'part-body' }, ...body))
+ }
+
+ /** 分析结论 · 计算:关键数的代入式、具名的数、结果表(引用的格高亮) */
+ function analysisCalcPart(step) {
+ const body = []
+ if (Array.isArray(step.formulas) && step.formulas.length) {
+ body.push(h('p', { class: 'sub' }, T.dFormulas), h('pre', { class: 'sql' }, step.formulas.map((f) => String(f.text || '')).join('\n')))
+ }
+ if (Array.isArray(step.values) && step.values.length) {
+ body.push(h('p', { class: 'sub' }, T.dValues), kv(step.values.map((v) => [String(v.key),
+ h('span', { class: v.cited ? 'hit' : null }, String(v.rendered ?? NONE))])))
+ }
+ const table = isObj(step.table) ? step.table : null
+ if (table && Array.isArray(table.columns) && Array.isArray(table.rows)) {
+ const labels = isObj(table.labels) ? table.labels : {}
+ const index = Array.isArray(table.row_index) ? table.row_index : table.rows.map((_, i) => i)
+ body.push(h('p', { class: 'sub' }, `${T.dResultTable}(${T.resultRows(table.rows.length, table.total_rows ?? table.rows.length)})`),
+ smallTable(table.columns.map((c) => labels[c] || c), table.columns, table.rows.map((r) => (Array.isArray(r)
+ ? r.map((v, j) => ((table.masked || []).includes(table.columns[j]) ? MASKED : v)) : r)), index,
+ isObj(table.highlight) ? table.highlight.cells : []))
+ if (table.window_truncated) body.push(muted(T.windowCut))
+ }
+ return h('section', { class: 'part', 'data-step': 'analysis_calc' }, h('h3', {}, T.calc), h('div', { class: 'part-body' }, ...body))
+ }
+
+ /** 分析结论 · 输入:角色、落到哪个 Q、用到的列和行、总数那一格和闭合核对、下钻的上一层 */
+ function analysisInputPart(step) {
+ const rows = Array.isArray(step.rows) ? step.rows.map((r) => `第 ${Number(r) + 1} 行`).join('、') : step.rows === 'all' ? T.allRows : null
+ const closure = isObj(step.closure) ? step.closure : null
+ const body = [kv([
+ [T.dRole, String(step.role_label || step.role || NONE)],
+ step.ref ? [T.dRef, h('code', {}, String(step.ref))] : null,
+ step.query ? [T.dQuery, String(step.query)] : null,
+ step.alias ? [T.dParent, String(step.alias)] : null,
+ step.node_id ? [T.dNode, nodeLabel(step.node_id)] : null,
+ Array.isArray(step.columns) && step.columns.length ? [T.dColumnsUsed, step.columns.join('、')] : null,
+ rows ? [T.dRowsUsed, rows] : null,
+ 'value' in step ? [T.dHeadline, `${step.column ? `第 ${Number(step.row) + 1} 行「${step.column}」 = ` : ''}${valueText(step.value)}`] : null,
+ closure ? [T.dClosure, closure.closed ? T.closureOk(closure.headline, closure.total) : T.closureBad(closure.headline, closure.total)] : null,
+ step.phrase ? [T.dPhrase, String(step.phrase)] : null,
+ ])]
+ const bits = integrity(step)
+ if (bits) body.push(bits)
+ return h('section', { class: 'part', 'data-step': 'analysis_input' },
+ h('h3', {}, `${T.analysisInput}${step.query || step.alias ? ` ${step.query || step.alias}` : ''}`), h('div', { class: 'part-body' }, ...body))
+ }
+
+ /** 分析结论 · 复算:按封存的输入重算、和封存的比(服务端算好的那一句) */
+ function analysisRecomputePart(step) {
+ const kind = step.status === 'same' ? 'ok' : step.status === 'different' ? 'bad' : 'na'
+ return h('section', { class: 'part', 'data-step': 'analysis_recompute' }, h('h3', {}, T.recompute),
+ h('div', { class: 'part-body' }, h('div', { class: 'chips' }, badge(kind, String(step.text || NONE)))))
+ }
+
+ /** 分析结论 · 封存:台账记没记过、哈希核对 */
+ function analysisSealPart(step) {
+ const body = [kv([[T.dArtifact, step.artifact ? h('code', {}, short(step.artifact)) : NONE],
+ [T.seal, step.ledger ? T.inLedger : T.notInLedger]])]
+ if (step.note) body.push(waiting(step.note))
+ const bits = integrity(step)
+ if (bits) body.push(bits)
+ return h('section', { class: 'part', 'data-step': 'analysis_seal' }, h('h3', {}, T.seal), h('div', { class: 'part-body' }, ...body))
+ }
+
const PARTS = {
metric: metricPart, input: inputPart, query: queryPart, run_input: runInputPart,
entity: entityPart, quote: quotePart,
+ analysis: analysisPart, analysis_method: analysisMethodPart, analysis_calc: analysisCalcPart,
+ analysis_input: analysisInputPart, analysis_recompute: analysisRecomputePart, analysis_seal: analysisSealPart,
}
/** 口径一步(口径账本给的标签,服务端已按遮罩给):来自口径的写明哪一版、级别、怎么认的、参数;相关口径写差在哪;
diff --git a/backend/app/report_export/template.html b/backend/app/report_export/template.html
index d2ef850c..44a2da5f 100644
--- a/backend/app/report_export/template.html
+++ b/backend/app/report_export/template.html
@@ -97,6 +97,13 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
.chipbtn { font: inherit; font-size: 12px; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer;
text-align: left; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
.hit { background: var(--mark); }
+.scope { margin-top: 16px; padding: 14px 18px; border: 1px dashed var(--border); border-radius: 10px; background: var(--panel); }
+.scope h3 { margin: 0 0 4px; font-size: 16px; }
+.scope > .muted { margin: 0 0 8px; font-size: 12px; }
+.scope-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 14px; margin: 0; font-size: 14px; }
+.scope-list dt { color: var(--faint); }
+.scope-list dd { margin: 0; min-width: 0; }
+.scope-list ul { margin: 0; padding-left: 1.2em; }
.foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); }
.foot p { margin: 4px 0; }
@media print {
@@ -115,7 +122,7 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
body { font-size: 14px; }
.page { padding: 20px 16px 56px; }
h1 { font-size: 20px; }
- .meta { grid-template-columns: minmax(0, 1fr); gap: 0; }
+ .meta, .scope-list { grid-template-columns: minmax(0, 1fr); gap: 0; }
.meta dt { margin-top: 6px; }
.doc { padding: 12px; }
}