Files
rengine/reports/theme.py
T
Yogesh OjhaandClaude Fable 5.1 0a69f3201c copy: state the fact, delete the explanation
Repo-wide sweep of every user-facing string: frontend components, routes,
config and stores; API details and route docs; shared definitions and
models; stage config help; report prose; MCP, toolbox, interest and
connector text; notification and worker messages.

Descriptions that explained, reassured, justified or restated their title
are deleted. Confirm dialogs name what is removed and stop. Failed actions
read "<Noun> not <verbed>", successes "<Noun> <verbed>". No reader address,
no product-as-subject, no em-dashes or parentheticals in prose.

Mirrors updated on both sides: EPSS band help, compare refusal reasons,
interest labels. Four tests asserting on old wording follow the new text.
The rules are condensed into CLAUDE.md under Writing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SK5o8xR96cbSnAfjYSTPSe
2026-09-12 11:35:07 +05:30

284 lines
10 KiB
Python

"""Themes are token files."""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
import yaml
from shared.definitions.report_theme import ColorTokens, ThemeTokens
from shared.definitions.reports import (
DARK_CHART_PALETTE,
DARK_SEVERITY_COLORS,
DEFAULT_CHART_PALETTE,
DEFAULT_SEVERITY_COLORS,
DENSITY_SCALE,
FONT_BY_KEY,
ReportStyle,
)
from shared.utils.color import ink as ink_of
from shared.utils.color import is_dark, mix
from shared.utils.color import tint as tint_of
from shared.utils.yaml_safe import DocumentTooLargeError, load_document
THEME_DIR = Path(__file__).resolve().parent / "themes"
_FALLBACK_CHART = list(DEFAULT_CHART_PALETTE)
_MONO_CHART = ["#22262e", "#474d58", "#6d7481", "#9aa1ad", "#c6ccd5"]
_MONO_SEVERITY = {
"critical": "#14181f",
"high": "#3b414c",
"medium": "#666d79",
"low": "#98a0ac",
"info": "#c3c9d2",
"unknown": "#c3c9d2",
}
class ThemeError(ValueError):
"""A theme file could not be read."""
_IMPORT_RE = re.compile(r"@import\b", re.I)
_URL_RE = re.compile(r"url\(\s*['\"]?([^)'\"]*)", re.I)
def check_css(css: str) -> None:
"""A theme may style."""
if not css:
return
if _IMPORT_RE.search(css):
msg = "@import is not accepted. Put the rules in the css block."
raise ThemeError(msg)
for target in _URL_RE.findall(css):
if not target.strip().lower().startswith("data:"):
msg = (
"url() accepts data: URIs only. "
f"Refused: {target.strip()[:80] or '(empty)'}"
)
raise ThemeError(msg)
def parse(source: str, *, slug: str = "") -> ThemeTokens:
try:
raw = load_document(source)
except DocumentTooLargeError as exc:
raise ThemeError(str(exc)) from exc
except yaml.YAMLError as exc:
msg = f"Not valid YAML: {exc}"
raise ThemeError(msg) from exc
if not isinstance(raw, dict):
msg = "A theme file must be a mapping of tokens."
raise ThemeError(msg)
try:
tokens = ThemeTokens.model_validate(raw)
except ValueError as exc:
msg = f"Not a valid theme: {exc}"
raise ThemeError(msg) from exc
if slug:
tokens.key = slug
if not tokens.key:
msg = "The theme has no key."
raise ThemeError(msg)
if not tokens.name:
tokens.name = tokens.key.replace("-", " ").title()
check_css(tokens.css)
return tokens
@lru_cache(maxsize=1)
def builtin_themes() -> dict[str, ThemeTokens]:
out: dict[str, ThemeTokens] = {}
if not THEME_DIR.is_dir():
return out
for path in sorted(THEME_DIR.glob("*.yaml")):
tokens = parse(path.read_text(encoding="utf-8"), slug=path.stem)
out[tokens.key] = tokens
return out
def builtin_source(slug: str) -> str:
path = THEME_DIR / f"{slug}.yaml"
return path.read_text(encoding="utf-8") if path.is_file() else ""
def resolve(tokens: ThemeTokens, style: ReportStyle) -> ThemeTokens:
"""Apply the report's own overrides on top of the theme."""
merged = tokens.model_copy(deep=True)
colour = merged.color
if style.accent:
colour.accent = style.accent
if style.accent_soft:
colour.accent_soft = style.accent_soft
if style.severity_colors:
colour.severity = {**colour.severity, **style.severity_colors}
if style.chart_palette:
colour.chart = list(style.chart_palette)
if style.heading_font:
merged.type.heading = style.heading_font
if style.body_font:
merged.type.body = style.body_font
if style.mono_font:
merged.type.mono = style.mono_font
if style.cover_layout:
merged.cover.layout = style.cover_layout
merged.type.base_size = style.base_font_size
merged.type.line_height = style.line_height
if style.mono_safe:
colour.chart = list(_MONO_CHART)
colour.severity = dict(_MONO_SEVERITY)
colour.accent = "#2c2f36"
colour.accent_soft = "#eeeff2"
merged.cover.art = "none"
paper_is_dark = is_dark(colour.page)
base_severity = DARK_SEVERITY_COLORS if paper_is_dark else DEFAULT_SEVERITY_COLORS
colour.severity = {**base_severity, **colour.severity}
if not colour.chart:
colour.chart = list(
DARK_CHART_PALETTE if paper_is_dark else DEFAULT_CHART_PALETTE
)
if not colour.link:
colour.link = colour.accent
return merged
def _wash(value: str, page: str) -> str:
"""A badge ground: a tint on light paper, the colour dropped into dark paper."""
return mix(page, value, 0.20) if is_dark(page) else tint_of(value)
def _label_ink(value: str, page: str) -> str:
"""Text that sits on that ground."""
return value if is_dark(page) else ink_of(value)
def _snap_weight(value: int) -> int:
"""WeasyPrint accepts only the hundreds."""
return max(100, min(900, round(value / 100) * 100))
def font_stack(key: str, fallback: str, families: dict[str, str] | None = None) -> str:
family = (families or {}).get(key) or (
FONT_BY_KEY[key].stack if key in FONT_BY_KEY else key
)
return f"'{family}', {fallback}" if family else fallback
def _scale_sizes(base: float, ratio: float, density: str) -> dict[str, float]:
tighten = DENSITY_SCALE.get(density, 1.0)
return {
"h1": round(base * ratio**3, 2),
"h2": round(base * ratio**2, 2),
"h3": round(base * ratio, 2),
"h4": round(base * 1.06, 2),
"small": round(base * 0.87, 2),
"micro": round(base * 0.775, 2),
"gap": round(0.92 * tighten, 3),
}
def css_variables(
tokens: ThemeTokens, style: ReportStyle, *, families: dict[str, str] | None = None
) -> str:
colour: ColorTokens = tokens.color
typography = tokens.type
layout = tokens.layout
sizes = _scale_sizes(typography.base_size, typography.scale, style.density)
lines = [
f"--r-page:{colour.page}",
f"--r-ink:{colour.ink}",
f"--r-ink-soft:{colour.ink_soft}",
f"--r-ink-faint:{colour.ink_faint}",
f"--r-rule:{colour.rule}",
f"--r-rule-strong:{colour.rule_strong}",
f"--r-surface:{colour.surface}",
f"--r-surface-soft:{colour.surface_soft}",
f"--r-accent:{colour.accent}",
f"--r-accent-soft:{colour.accent_soft}",
f"--r-accent-ink:{colour.accent_ink}",
f"--r-link:{colour.link}",
f"--r-cover-bg:{tokens.cover.background or colour.accent}",
f"--r-cover-ink:{'#ffffff' if tokens.cover.ink == 'light' else colour.ink}",
f"--r-font-heading:{font_stack(typography.heading, 'sans-serif', families)}",
f"--r-font-body:{font_stack(typography.body, 'sans-serif', families)}",
f"--r-font-mono:{font_stack(typography.mono, 'monospace', families)}",
f"--r-size:{typography.base_size}pt",
f"--r-lh:{typography.line_height}",
f"--r-h1:{sizes['h1']}pt",
f"--r-h2:{sizes['h2']}pt",
f"--r-h3:{sizes['h3']}pt",
f"--r-h4:{sizes['h4']}pt",
f"--r-small:{sizes['small']}pt",
f"--r-micro:{sizes['micro']}pt",
f"--r-gap:{sizes['gap']}rem",
f"--r-h-weight:{_snap_weight(typography.heading_weight)}",
f"--r-h-track:{typography.heading_tracking}em",
f"--r-label-track:{typography.label_tracking}em",
f"--r-label-case:{'uppercase' if typography.uppercase_labels else 'none'}",
f"--r-run-track:{round(typography.label_tracking * 0.35, 4)}em",
f"--r-align:{'justify' if style.justify else 'left'}",
f"--r-hyphens:{'auto' if style.hyphenate else 'manual'}",
f"--r-chapter-break:{'page' if style.chapter_breaks else 'auto'}",
f"--r-chapter-rule:{'0' if style.chapter_breaks else 'var(--r-rule-w) solid var(--r-rule)'}",
f"--r-chapter-pad:{'0' if style.chapter_breaks else 'calc(var(--r-block-gap) * 1.6)'}",
f"--r-rule-w:{layout.rule_width}pt",
f"--r-radius:{layout.radius}px",
f"--r-pill:{'999px' if layout.pill else f'{layout.radius}px'}",
f"--r-block-gap:{round(layout.block_gap, 3)}rem",
f"--r-chart-stroke:{layout.chart_stroke}",
f"--r-watermark-opacity:{style.watermark_opacity}",
]
for key, value in colour.severity.items():
lines.append(f"--r-sev-{key}:{value}")
lines.append(f"--r-sev-{key}-wash:{_wash(value, colour.page)}")
lines.append(f"--r-sev-{key}-ink:{_label_ink(value, colour.page)}")
for index, value in enumerate(colour.chart[:8], start=1):
lines.append(f"--r-chart-{index}:{value}")
lines.append(f"--r-accent-wash:{_wash(colour.accent, colour.page)}")
lines.append(f"--r-accent-ink-on-wash:{_label_ink(colour.accent, colour.page)}")
body = ";".join(lines)
return f":root{{{body}}}"
def theme_summary(tokens: ThemeTokens, origin: str) -> dict:
colour = tokens.color
dark_paper = is_dark(colour.page)
severity = DARK_SEVERITY_COLORS if dark_paper else DEFAULT_SEVERITY_COLORS
chart = DARK_CHART_PALETTE if dark_paper else DEFAULT_CHART_PALETTE
return {
"slug": tokens.key,
"name": tokens.name,
"description": tokens.description,
"author": tokens.author,
"origin": origin,
"accent": colour.accent,
"page": colour.page,
"ink": colour.ink,
"ink_soft": colour.ink_soft,
"ink_faint": colour.ink_faint,
"rule": colour.rule,
"surface": colour.surface,
"cover_background": tokens.cover.background or colour.accent,
"cover_ink": "#ffffff" if tokens.cover.ink == "light" else colour.ink,
"cover_layout": tokens.cover.layout,
"cover_art": tokens.cover.art,
"heading_font": tokens.type.heading,
"body_font": tokens.type.body,
"mono_font": tokens.type.mono,
"heading_style": tokens.layout.heading,
"table_style": tokens.layout.table,
"finding_style": tokens.layout.finding,
"radius": tokens.layout.radius,
"uppercase_labels": tokens.type.uppercase_labels,
"severity": {**severity, **colour.severity},
"chart": list(colour.chart or chart),
}