Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 52 additions & 22 deletions bin/typikon-check-assets
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ reimplement Zola's site-static-over-theme-static merge — it runs against the r
merged output of a real `zola build`, so a passing run proves the shipped asset is
actually there, not that a hand-rolled path-resolution copy agrees with Zola's.

Scope: `config.extra.*` only (favicon_path, logo_path, og_image) — the three
keys docs/AGENTIC.md's brand-identity table documents as consumer-declared,
Scope: `config.extra.*` only (favicon_path, logo_path, og_image, consumer_css) —
the keys docs/AGENTIC.md's brand-identity table documents as consumer-declared,
site-wide asset paths. Per-page/per-section `extra.og_image` overrides are
page content, not consumer brand configuration, and out of scope for this check.

Expand All @@ -29,7 +29,9 @@ page content, not consumer brand configuration, and out of scope for this check.
shipped default would be exactly the kind of silent breakage this exists to catch.
`logo_path` and `og_image` have no default and are only checked when the consumer
sets them (templates/base.html:44, partials/nav.html:8, partials/ld-organization.html:14
all gate on the key being present).
all gate on the key being present). `consumer_css` (forkwright/typikon#55) has no
default (an unset or empty list renders zero extra `<link>` tags) and, unlike the
three scalar keys, is a LIST — every entry is checked the same way, individually.

NOTE: must run AFTER a `zola build` into `<root>/public` — see bin/typikon-check,
which runs this as a stage immediately after its own zola-build stage.
Expand Down Expand Up @@ -111,21 +113,18 @@ def main() -> int:
skipped = 0
failed = 0

for key, default in ASSET_DEFAULTS.items():
raw = extra.get(key, default)
if raw is None:
skipped += 1
continue
def check_one(key: str, raw: object) -> bool:
"""Resolve `raw` (a single declared path) against `public/` and
report pass/fail. Shared by the scalar ASSET_DEFAULTS loop and the
consumer_css list loop below — one resolution rule for every
consumer-declared asset path, not two copies that could drift."""
if not isinstance(raw, str) or not raw:
checked += 1
failed += 1
print(
json.dumps({"key": key, "path": raw, "error": f"config.extra.{key} is not a non-empty string"}),
file=sys.stderr,
)
continue
return False

checked += 1
# WHY lstrip("/"): every documented value and every existing consumer
# usage (templates/partials/nav.html:8's `/{{ ... }}`, ld-organization.html:14's
# `base_url ~ "/" ~ ...`) treats the configured value as root-relative
Expand All @@ -143,7 +142,6 @@ def main() -> int:
relative = raw.lstrip("/")
resolved = (public / relative).resolve()
if not resolved.is_relative_to(public_resolved):
failed += 1
print(
json.dumps({
"key": key,
Expand All @@ -152,19 +150,51 @@ def main() -> int:
}),
file=sys.stderr,
)
continue
return False
if resolved.is_file():
return True
print(
json.dumps({
"key": key,
"path": raw,
"error": f"no file at {resolved} — config.extra.{key} = {raw!r} does not resolve to a built asset",
}),
file=sys.stderr,
)
return False

for key, default in ASSET_DEFAULTS.items():
raw = extra.get(key, default)
if raw is None:
skipped += 1
continue
checked += 1
if check_one(key, raw):
passed += 1
else:
failed += 1
print(
json.dumps({
"key": key,
"path": raw,
"error": f"no file at {resolved} — config.extra.{key} = {raw!r} does not resolve to a built asset",
}),
file=sys.stderr,
)

# consumer_css (forkwright/typikon#55): a LIST, unlike the three scalar
# keys above — unset or empty is a valid "no extra stylesheet" state
# (templates/base.html's hook renders zero <link> tags), not an error,
# so an absent/empty list is a single skip rather than a failure.
consumer_css = extra.get("consumer_css")
if consumer_css is None or consumer_css == []:
skipped += 1
elif not isinstance(consumer_css, list):
checked += 1
failed += 1
print(
json.dumps({"key": "consumer_css", "path": consumer_css, "error": "config.extra.consumer_css is not a list"}),
file=sys.stderr,
)
else:
for i, raw in enumerate(consumer_css):
checked += 1
if check_one(f"consumer_css[{i}]", raw):
passed += 1
else:
failed += 1

print(json.dumps({"checked": checked, "passed": passed, "skipped": skipped, "failed": failed}))
return 1 if failed else 0
Expand Down
62 changes: 39 additions & 23 deletions ci/check-interactive-contrast-selftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ class this checker's own review caught — an unanchored property regex
happens to exercise the bug today (the review found it by luck-of-source-
order, not by any live failure).

PART B (end-to-end, real file, restore-guaranteed): mutates the ACTUAL
static/css/style.css the same way a future regression would, runs
check-interactive-contrast.py as a real subprocess against it, asserts the
expected failure, then restores the original bytes in a `finally` and
re-verifies the restore is byte-identical before declaring success. This
is the #64 regression and the coverage-scan gap from the PR body's own
five-mutation list, now committed instead of hand-typed.
PART B (end-to-end, real files, restore-guaranteed): mutates the ACTUAL
static/css/style.css and/or static/css/skins/leather.css the same way a
future regression would, runs check-interactive-contrast.py as a real
subprocess against them, asserts the expected failure, then restores the
original bytes of both in a `finally` and re-verifies the restore is
byte-identical before declaring success. This is the #64 regression and
the coverage-scan gap from the PR body's own five-mutation list, now
committed instead of hand-typed. The #64 mutation targets the skin file
specifically (forkwright/typikon#55 moved the dye-token mapping there;
see check-interactive-contrast.py's FIRST_PARTY_SKINS).

NOTE: runs standalone (no consumer site or zola build needed) as part of
ci/run-fixtures.sh. Order relative to check-interactive-contrast.py in that
Expand All @@ -52,6 +55,10 @@ class this checker's own review caught — an unanchored property regex
THEME_ROOT = CI_DIR.parent
CHECK_SCRIPT = CI_DIR / "check-interactive-contrast.py"
STYLE_CSS = THEME_ROOT / "static" / "css" / "style.css"
# The #64 regression's actual token mapping lives here since
# forkwright/typikon#55 split the dye palette out of core — see that
# skin's own :root block.
LEATHER_SKIN_CSS = THEME_ROOT / "static" / "css" / "skins" / "leather.css"


def _load_check_module() -> ModuleType:
Expand Down Expand Up @@ -140,22 +147,28 @@ def _part_a(mod: ModuleType, failures: list[str]) -> None:
def _part_b(failures: list[str]) -> None:
original = STYLE_CSS.read_bytes()
original_text = original.decode("utf-8")
skin_original = LEATHER_SKIN_CSS.read_bytes()
skin_original_text = skin_original.decode("utf-8")

try:
# B1 — the #64 regression itself: revert the fixed nav-hover token
# back to the pre-fix dye color.
regressed_needle = ".nav-links a:nth-child(3):hover { color: var(--aporia-interactive); }"
regressed_replacement = ".nav-links a:nth-child(3):hover { color: var(--aporia); }"
if original_text.count(regressed_needle) != 1:
# B1 — the #64 regression itself, one hop deeper since
# forkwright/typikon#55: core's .nav-links a:nth-child(3):hover
# resolves through --accent-3, and the leather skin's OWN :root is
# what maps --accent-3 to --aporia-interactive (not raw --aporia).
# Reverting that mapping in the skin is the exact regression #64
# was filed over, now expressed one level of indirection down.
regressed_needle = "--accent-3: var(--aporia-interactive);"
regressed_replacement = "--accent-3: var(--aporia);"
if skin_original_text.count(regressed_needle) != 1:
failures.append(
"B1 setup: expected exactly one occurrence of the pre-fix "
f"#64 rule shape in {STYLE_CSS} to mutate; found "
f"{original_text.count(regressed_needle)} — this fixture is "
f"#64 mapping in {LEATHER_SKIN_CSS} to mutate; found "
f"{skin_original_text.count(regressed_needle)} — this fixture is "
"stale against the current source and needs updating"
)
else:
STYLE_CSS.write_text(
original_text.replace(regressed_needle, regressed_replacement, 1),
LEATHER_SKIN_CSS.write_text(
skin_original_text.replace(regressed_needle, regressed_replacement, 1),
encoding="utf-8",
)
result = _run_check()
Expand All @@ -169,7 +182,7 @@ def _part_b(failures: list[str]) -> None:
"B1: reverting the #64 fix failed, but not with the "
f"expected WCAG floor message; stderr:\n{result.stderr}"
)
STYLE_CSS.write_text(original_text, encoding="utf-8")
LEATHER_SKIN_CSS.write_text(skin_original_text, encoding="utf-8")

# B2 — coverage-scan negative case: a brand-new, unreviewed
# interactive-state color rule must fail closed, not pass silently.
Expand All @@ -189,26 +202,29 @@ def _part_b(failures: list[str]) -> None:
STYLE_CSS.write_text(original_text, encoding="utf-8")

finally:
# SAFETY: never leave the real stylesheet mutated, even if an
# SAFETY: never leave the real stylesheets mutated, even if an
# assertion above raised instead of appending to `failures`.
STYLE_CSS.write_bytes(original)
LEATHER_SKIN_CSS.write_bytes(skin_original)

restored = STYLE_CSS.read_bytes()
if restored != original:
skin_restored = LEATHER_SKIN_CSS.read_bytes()
if restored != original or skin_restored != skin_original:
failures.append(
f"B: {STYLE_CSS} did not restore byte-identical after the "
"mutation fixtures — the gate has corrupted the real stylesheet"
f"B: {STYLE_CSS} and/or {LEATHER_SKIN_CSS} did not restore "
"byte-identical after the mutation fixtures — the gate has "
"corrupted the real stylesheet(s)"
)
return

# B3 — with the file genuinely restored, the checker must pass again.
# B3 — with both files genuinely restored, the checker must pass again.
# Proves B1/B2's failures were caused by the mutations, not by some
# other break this fixture introduced.
result = _run_check()
if result.returncode != 0:
failures.append(
"B3: check-interactive-contrast.py did not pass against the "
f"restored, unmodified style.css (exit {result.returncode}); "
f"restored, unmodified stylesheets (exit {result.returncode}); "
f"stderr:\n{result.stderr}"
)

Expand Down
39 changes: 29 additions & 10 deletions ci/check-interactive-contrast.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,13 @@
domain, not this one. It resolves to var(--text) against var(--bg) =
15.77:1, so there is no live defect either way.
- `.home-page:has(.mark-*:hover)` / `:has(.triad-mark.settled .triad-N:hover)`
(style.css:394-404, 1230-1249) shift the home page's background
through a decorative gradient. This is pre-adjudicated, not skipped
out of convenience: ci/pa11y.config.js's own `ignore`-list comment
states the underlying text stays "on archival-paper bg,
contrast-AA-clean" by design and marks the class ratio-aware. This
script does not re-litigate that documented call.
(static/css/skins/leather.css as of forkwright/typikon#55; originally
in style.css itself) shift the home page's background through a
decorative gradient. This is pre-adjudicated, not skipped out of
convenience: ci/pa11y.config.js's own `ignore`-list comment states the
underlying text stays "on archival-paper bg, contrast-AA-clean" by
design and marks the class ratio-aware. This script does not
re-litigate that documented call.
- `:active`, `:visited`, `:disabled` currently have ZERO rules anywhere
in style.css (confirmed by the scan in part 3 finding none) — so
every element's active/visited/disabled state renders with the same
Expand Down Expand Up @@ -105,6 +106,23 @@
THEME_ROOT = Path(__file__).resolve().parent.parent
STYLE_CSS = THEME_ROOT / "static" / "css" / "style.css"

# First-party skins this theme ships (forkwright/typikon#55): their :root
# token overrides are cascade-loaded AFTER style.css by a consumer that
# opts in (config.extra.consumer_css), so the SAME theme-owned selectors
# this script protects (.nav-links a:nth-child(3):hover, .triad-3, ...)
# render through whatever hue a skin maps its --accent-N tokens to.
# Scanning core alone would leave that mapping — including the exact
# color pair (--aporia / --aporia-interactive) #64 was filed over —
# unchecked the moment it moved out of style.css. This does NOT extend to
# arbitrary consumer-authored CSS: MATRIX is hand-curated over this
# theme's OWN selectors, so an unknown consumer skin with its own novel
# selectors is out of scope here exactly as it always was.
FIRST_PARTY_SKINS = [THEME_ROOT / "static" / "css" / "skins" / "leather.css"]


def load_theme_css() -> str:
return "\n".join(p.read_text(encoding="utf-8") for p in [STYLE_CSS, *FIRST_PARTY_SKINS])

RULE_RE = re.compile(r"([^{}]+)\{([^{}]*)\}")
STATE_PSEUDO_RE = re.compile(r":(hover|focus-visible|focus|active|visited|disabled)\b")
COLOR_AFFECTING_RE = re.compile(
Expand Down Expand Up @@ -206,7 +224,8 @@ def resolve_chain(css_text: str, chain: list[str], prop_alt: str) -> tuple[str,
(".nav-links a:nth-child(2):hover", "hover", [".nav-links a:nth-child(2):hover"], ("literal", "bg"), 11.1, 400,
"dye-color hover override"),
(".nav-links a:nth-child(3):hover", "hover", [".nav-links a:nth-child(3):hover"], ("literal", "bg"), 11.1, 400,
"the original #64 fix — must resolve to --aporia-interactive, not raw --aporia"),
"core resolves to --accent-3 (neutral by default); the leather skin's --accent-3 must stay "
"mapped to --aporia-interactive, not raw --aporia — the original #64 fix, now one hop deeper"),
(".nav-links a:nth-child(4):hover", "hover", [".nav-links a:nth-child(4):hover"], ("literal", "bg"), 11.1, 400,
"dye-color hover override"),
(".nav-links a:nth-child(5):hover", "hover", [".nav-links a:nth-child(5):hover"], ("literal", "bg"), 11.1, 400,
Expand Down Expand Up @@ -267,7 +286,7 @@ def resolve_chain(css_text: str, chain: list[str], prop_alt: str) -> tuple[str,
"color:var(--bg) text on background:var(--text) — both declared directly"),
(".buy-btn:hover", "hover", [".buy-btn:hover", ".buy-btn"], ("chain", [".buy-btn:hover"]), 11.1, 400,
"hover declares no `color`; chain falls back to .buy-btn (still --bg). "
"background is declared directly on the hover rule (--aima)"),
"background is declared directly on the hover rule (--accent-1)"),

# --- 404 back link ---
(".back-link", "default", [".back-link"], ("literal", "bg"), 11.1, 400,
Expand Down Expand Up @@ -357,7 +376,7 @@ def resolve_chain(css_text: str, chain: list[str], prop_alt: str) -> tuple[str,
".home-page:has(.mark-aima:hover)": "decorative bg gradient — pre-adjudicated, see module docstring",
".home-page:has(.mark-thanatochromia:hover)": "decorative bg gradient — pre-adjudicated, see module docstring",
".home-page:has(.mark-aporia:hover)": "decorative bg gradient — pre-adjudicated, see module docstring",
"a:hover": "text-decoration-color only, not the glyph color; --aima is 12.25:1 vs --bg regardless",
"a:hover": "text-decoration-color only, not the glyph color, which is what 1.4.3 measures",
".home .home-tagline:hover span": "opacity toggle only, no color",
".triad-mark.settled .triad-word:hover .english": "opacity toggle only, no color",
".triad-mark.settled .triad-word:hover .greek": "opacity toggle only, no color",
Expand Down Expand Up @@ -414,7 +433,7 @@ def find_state_affecting_selectors(css_text: str) -> dict[str, list[str]]:


def main() -> int:
css_text = STYLE_CSS.read_text(encoding="utf-8")
css_text = load_theme_css()
css_text_nocomments = CSS_COMMENT_RE.sub(" ", css_text)
tokens = parse_root_tokens(css_text)

Expand Down
43 changes: 38 additions & 5 deletions ci/contrast.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
LARGE_TEXT_PX_BOLD = 18.66
BOLD_WEIGHT_THRESHOLD = 700

TOKEN_DECL_RE = re.compile(r"--([\w-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;")
TOKEN_HEX_RE = re.compile(r"--([\w-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;")
TOKEN_ALIAS_RE = re.compile(r"--([\w-]+)\s*:\s*var\(--([\w-]+)\)\s*;")


def srgb_to_linear(channel: int) -> float:
Expand Down Expand Up @@ -73,10 +74,42 @@ def blend_over(fg_hex: str, backdrop_hex: str, alpha: float) -> str:


def parse_root_tokens(css_text: str) -> dict[str, str]:
root_match = re.search(r":root\s*\{([^{}]*)\}", css_text, re.DOTALL)
if not root_match:
return {}
return dict(TOKEN_DECL_RE.findall(root_match.group(1)))
"""Merge every top-level `:root{}` block's custom-property declarations,
in source order — a later block redeclaring a name wins, mirroring the
real cascade a consumer's skin CSS gets when it loads after core's
style.css (forkwright/typikon#55: `css_text` may be a concatenation of
core plus one or more first-party skin files, each with their own
`:root{}`).

A declaration whose value is itself `var(--other-token)` — not a literal
hex — is resolved against this same merged set (chased through further
aliases if needed) rather than being dropped. This lets a skin write
`--accent-3: var(--aporia-interactive);` instead of duplicating the hex
literal: one fact (the hex), one place (the token it's declared on),
everything else an alias of it."""
hex_decls: dict[str, str] = {}
alias_decls: dict[str, str] = {}
for root_body in re.findall(r":root\s*\{([^{}]*)\}", css_text, re.DOTALL):
for name, hex_value in TOKEN_HEX_RE.findall(root_body):
hex_decls[name] = hex_value
alias_decls.pop(name, None)
for name, target in TOKEN_ALIAS_RE.findall(root_body):
alias_decls[name] = target
hex_decls.pop(name, None)

def resolve(name: str, seen: frozenset[str]) -> str | None:
if name in hex_decls:
return hex_decls[name]
if name in alias_decls and name not in seen:
return resolve(alias_decls[name], seen | {name})
return None

resolved: dict[str, str] = {}
for name in {**hex_decls, **alias_decls}:
value = resolve(name, frozenset())
if value is not None:
resolved[name] = value
return resolved


def text_contrast_floor(font_px: float, font_weight: int) -> float:
Expand Down
Loading