diff --git a/bin/typikon-check-assets b/bin/typikon-check-assets index 6f1472a..decef8c 100755 --- a/bin/typikon-check-assets +++ b/bin/typikon-check-assets @@ -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. @@ -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 `` 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 `/public` — see bin/typikon-check, which runs this as a stage immediately after its own zola-build stage. @@ -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 @@ -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, @@ -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 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 diff --git a/ci/check-interactive-contrast-selftest.py b/ci/check-interactive-contrast-selftest.py index 6430886..a88cf4c 100755 --- a/ci/check-interactive-contrast-selftest.py +++ b/ci/check-interactive-contrast-selftest.py @@ -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 @@ -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: @@ -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() @@ -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. @@ -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}" ) diff --git a/ci/check-interactive-contrast.py b/ci/check-interactive-contrast.py index 3ec499e..223e81e 100755 --- a/ci/check-interactive-contrast.py +++ b/ci/check-interactive-contrast.py @@ -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 @@ -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( @@ -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, @@ -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, @@ -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", @@ -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) diff --git a/ci/contrast.py b/ci/contrast.py index 1b9ffe1..1b9d90d 100644 --- a/ci/contrast.py +++ b/ci/contrast.py @@ -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: @@ -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: diff --git a/docs/AGENTIC.md b/docs/AGENTIC.md index 5da1862..368af7d 100644 --- a/docs/AGENTIC.md +++ b/docs/AGENTIC.md @@ -197,15 +197,19 @@ The substrate is design-family neutral. Brand-specific values go in `config.toml | `font_preload` | which `.woff2` files preload at first paint | | `nav_items`, `footer_links` | navigation structure | | `[extra.author]` | atom feed `` + JSON-LD Article author | +| `consumer_css` | list of stylesheet paths, each ``ed after core's own `style.css`, in order (`templates/base.html`'s Consumer stylesheet hook) | -If a brand needs a *visual* override beyond these (different scale ratio, different color palette, different type pairing), redeclare the relevant `:root` custom properties in a consumer-side CSS file loaded after `style.css`. **Do not edit typikon's `static/css/style.css`** for one-off site needs — that's a fork by mutation. +If a brand needs a *visual* override beyond the table above (different scale ratio, different color palette, different type pairing), declare it via `consumer_css` and redeclare the relevant `:root` custom properties in that file. Core's own interactive-state CSS (nav hover, buttons, the home triad mark, FAQ anchors, ...) never hard-codes a hue — it resolves through four semantic tokens, `--accent-1` through `--accent-4`, which default to a neutral `--text-mid` and exist solely for a skin to redeclare. `static/css/skins/leather.css` is the first-party example: it maps those four tokens to Ardent Leatherworks' dye palette and carries that brand's own content-authoring classes (`.dye-entry-*`, `.swatch-*`, `.dye-marks`) — copy its shape, not its colors, for a new skin. **Do not edit typikon's `static/css/style.css`** for one-off site needs — that's a fork by mutation. + +Beyond styles, `templates/base.html`'s own header comment documents the full design/extension surface (forkwright/typikon#55): a `{% block %}` for head metadata, nav, footer, structured data (JSON-LD), scripts, and page composition, each with a sane default a consumer only overrides when it needs to. A consumer template extends `base.html` and overrides just the block(s) it needs — it does not need to copy the whole file to add a stylesheet, a nav item, or a JSON-LD field. ### 3. When to extend typikon vs. override locally | You need to | Where it goes | |------------------------------------------------------------|--------------------------------------------------------------------------| -| Change one site's color palette / type / scale | Consumer-side CSS overriding `:root` tokens | -| Add a one-off CSS class used in one site's content | Consumer-side CSS | +| Change one site's color palette / type / scale | `consumer_css` entry redeclaring `:root` tokens (incl. `--accent-1..4`) | +| Add a one-off CSS class used in one site's content | `consumer_css` entry | +| Add a stylesheet, head element, or footer content of your own | Override the relevant `{% block %}` in a template that `{% extends "base.html" %}` — see that file's header comment. Not a reason to shadow `base.html` itself | | Add a content type (FAQ, sizing-guide, recipe, gallery) | typikon — schema + template + AGENTIC + fixture coverage | | Add an optional frontmatter field shared by ≥2 sites | typikon — extend the relevant schema; every content type's `extra` is closed (`unevaluatedProperties: false` or, for journal-entry/product/faq/sizing-guide, plain `additionalProperties: false`), so a new field is a schema edit, never an ambient allowance | | Override one page's HTML structure with a custom template | Consumer-side template under `/templates/.html` (Zola overrides typikon) **and** a `schemas/registry.toml` entry — see `docs/SCHEMAS.md#consumer-schema-registry`. A custom `template` with no registry entry fails validation; it does not fall back to `page`'s shape | diff --git a/examples/sample-blog/config.toml b/examples/sample-blog/config.toml index 49efbbc..1bbbc33 100644 --- a/examples/sample-blog/config.toml +++ b/examples/sample-blog/config.toml @@ -1,4 +1,6 @@ -# Sample blog — exercises section + journal-entry + page schemas. +# Sample blog — exercises section + journal-entry + page schemas, and +# (forkwright/typikon#55) a consumer-owned accent palette via consumer_css +# instead of the shipped leather skin — see examples/sample-shop for that. title = "Sample Blog" description = "A typikon fixture demonstrating section + journal-entry + page schemas with a different brand identity than ardent." base_url = "https://sample-blog.example.com" @@ -18,6 +20,7 @@ logo_path = "img/logo.svg" theme_color = "#FBF7EC" og_locale = "en_US" founding_date = "2026" +consumer_css = ["css/site.css"] font_preload = ["/fonts/eb-garamond-variable.woff2", "/fonts/spectral-400.woff2"] diff --git a/examples/sample-blog/content/_index.md b/examples/sample-blog/content/_index.md index 46b9d87..dbc3d09 100644 --- a/examples/sample-blog/content/_index.md +++ b/examples/sample-blog/content/_index.md @@ -7,4 +7,9 @@ template = "index.html" body_class = "home-page" home_logo = "img/logo.svg" home_tagline = "Notes on craft and attention." + +[extra.triad] +greek = ["λόγος", "τέχνη", "χρόνος"] +english = ["word", "craft", "time"] +target = "/about/" +++ diff --git a/examples/sample-blog/static/css/site.css b/examples/sample-blog/static/css/site.css new file mode 100644 index 0000000..df5c023 --- /dev/null +++ b/examples/sample-blog/static/css/site.css @@ -0,0 +1,17 @@ +/* + * Sample Blog — consumer-side stylesheet (forkwright/typikon#55 fixture). + * + * Loaded after core's static/css/style.css via this site's + * config.toml [extra] consumer_css hook. Redeclares ONLY the four + * semantic accent tokens core defines with a neutral default — a + * deliberately different, non-Leather palette, proving a consumer can + * establish its own visual identity without touching core CSS, without + * a skin, and without shadowing any template. + */ + +:root { + --accent-1: #14495E; /* marine teal */ + --accent-2: #4A3B7A; /* violet */ + --accent-3: #0F6B52; /* pine */ + --accent-4: #2E6B8A; /* sky teal */ +} diff --git a/examples/sample-shop/config.toml b/examples/sample-shop/config.toml index a6ecf67..84119ba 100644 --- a/examples/sample-shop/config.toml +++ b/examples/sample-shop/config.toml @@ -1,4 +1,7 @@ -# Sample shop — exercises section + product + page + faq schemas. +# Sample shop — exercises section + product + page + faq schemas, and +# (forkwright/typikon#55) the shipped first-party leather skin via +# consumer_css — see examples/sample-blog for a consumer-owned palette +# instead. title = "Sample Shop" description = "A typikon fixture demonstrating section + product + page + faq schemas with a different brand identity than ardent or sample-blog." base_url = "https://sample-shop.example.com" @@ -18,6 +21,7 @@ logo_path = "img/logo.svg" theme_color = "#F0EFE5" og_locale = "en_US" founding_date = "2026" +consumer_css = ["css/skins/leather.css"] font_preload = ["/fonts/eb-garamond-variable.woff2", "/fonts/spectral-400.woff2"] diff --git a/examples/sample-shop/content/_index.md b/examples/sample-shop/content/_index.md index a35ceb1..3b5a668 100644 --- a/examples/sample-shop/content/_index.md +++ b/examples/sample-shop/content/_index.md @@ -7,4 +7,9 @@ template = "index.html" body_class = "home-page" home_logo = "img/logo.svg" home_tagline = "A fixture for the typikon substrate." + +[extra.triad] +greek = ["αἷμα", "θανατοχρωμία", "ἀπορία"] +english = ["blood", "death-color", "impasse"] +target = "/dyes/" +++ diff --git a/examples/sample-shop/content/dyes.md b/examples/sample-shop/content/dyes.md new file mode 100644 index 0000000..a99555d --- /dev/null +++ b/examples/sample-shop/content/dyes.md @@ -0,0 +1,40 @@ ++++ +title = "Dyes" +description = "Fixture page exercising the leather skin's dye-entry / swatch content classes (forkwright/typikon#55)." ++++ + +This page exists to prove the leather skin's own content-authoring classes +(`.dye-entry-*`, `.dye-swatch`, `.swatch-*`, `.dye-marks`) still render once +moved out of core and opted into via `consumer_css` — no core template emits +these; they are raw HTML in this fixture's own markdown, exactly how a real +consumer would author them. + +
+

Αἷμα (aima)

+ +

Blood-red, iron-mordanted.

+
+ +
+

Θανατοχρωμία (thanatochromia)

+ +

Death-color, deep violet-black.

+
+ +
+

Ἀπορία (aporia)

+ +

Impasse-green, the dye that can't decide.

+
+ +
+

Natural (undyed)

+ +

Unmordanted, the leather's own color.

+
+ +
+ + + +
diff --git a/static/css/skins/leather.css b/static/css/skins/leather.css new file mode 100644 index 0000000..504bf98 --- /dev/null +++ b/static/css/skins/leather.css @@ -0,0 +1,249 @@ +/* + * Leather — first-party skin for typikon (forkwright/typikon#55) + * Ardent Leatherworks' iron-mordanted botanical dye identity: dark + * academia x Japanese workwear, dye-named accent colors, and the + * interactive dye vocabulary (.dye-entry-*, .swatch-*, .dye-marks) a + * consumer's markdown content can opt into. None of these classes are + * emitted by any core template — they exist purely for content authored + * against this brand. + * + * Loaded after static/css/style.css via config.toml's [extra] + * consumer_css list (base.html renders each path in order, see that + * file's Consumer stylesheet hook comment). Every core structural + * selector that used to hard-code a dye color now resolves through the + * neutral --accent-1..4 tokens style.css's :root defines — this file's + * only job is to redeclare those four to this brand's actual hues, so + * a consumer wanting a DIFFERENT identity never has to touch, fork, or + * even read this file. + */ + +:root { + --aima: #581523; + --aima-aged: #4A1A1A; + --thanatochromia: #2C1B3A; + --thanatochromia-aged: #382838; + --aporia: #5C8E63; + --aporia-aged: #4A7A5A; + /* WHY: --aporia is the true dye hue (used for swatches/backgrounds) and + only reaches 3.44:1 on --bg, below WCAG 2.2 AA's 4.5:1 for small text. + Interactive text uses this darker same-hue token instead; the dye + tokens above stay unmutated so swatches keep their accurate color. */ + --aporia-interactive: #4A7350; + --natural: #8B5A2B; + --natural-aged: #5C3A1F; + + /* The brand mapping: every core hover/accent rule (.nav-links, .buy-btn, + .triad-1/2/3, .entry-nav, .faq-anchor, a:hover's underline, ...) + resolves through these four names, never a dye name directly — so + this is the ONLY place that mapping lives. Position 3 stays the + WCAG-fixed --aporia-interactive, not raw --aporia (forkwright/typikon#64) + — ci/check-interactive-contrast.py scans this file precisely so a + regression here (like #64's) fails the gate exactly as it would in core. */ + --accent-1: var(--aima); + --accent-2: var(--thanatochromia); + --accent-3: var(--aporia-interactive); + --accent-4: var(--natural); +} + +/* === DYE MARKS (home page hover preview) === */ +.dye-marks { + display: flex; + gap: var(--space-s); + margin-top: var(--space-m); +} + +.mark { + width: 10px; + height: 10px; + border-radius: 50%; + transition: transform 0.3s ease; + cursor: pointer; +} + +.mark:hover { + transform: scale(1.5); +} + +.mark-aima { background: var(--aima); } +.mark-thanatochromia { background: var(--thanatochromia); } +.mark-aporia { background: var(--aporia); } + +/* Background transitions on mark hover */ +.home-page { + transition: background 0.5s ease; +} + +.home-page:has(.mark-aima:hover) { + background: linear-gradient(135deg, #F5F0E8 0%, #E8D5D0 50%, #D4B8B0 100%); +} + +.home-page:has(.mark-thanatochromia:hover) { + background: linear-gradient(135deg, #F5F0E8 0%, #DDD8E8 50%, #C8C0D8 100%); +} + +.home-page:has(.mark-aporia:hover) { + background: linear-gradient(135deg, #F5F0E8 0%, #D8E8D8 50%, #C0D8C0 100%); +} + +/* === DYES === */ +.dye-entry { + margin: var(--space-l) 0; + padding: var(--space-m); + border: 1px solid var(--rule); + transition: background 0.3s ease; +} + +/* Each dye entry gets its own color background */ +.dye-entry-aima { + background: linear-gradient(145deg, + rgba(88, 21, 35, 0.06) 0%, + rgba(88, 21, 35, 0.12) 50%, + rgba(88, 21, 35, 0.08) 100%); +} + +.dye-entry-thanatochromia { + background: linear-gradient(145deg, + rgba(44, 27, 58, 0.06) 0%, + rgba(44, 27, 58, 0.12) 50%, + rgba(44, 27, 58, 0.08) 100%); +} + +.dye-entry-natural { + background: linear-gradient(145deg, + rgba(139, 90, 43, 0.06) 0%, + rgba(139, 90, 43, 0.12) 50%, + rgba(139, 90, 43, 0.08) 100%); +} + +.dye-entry-aporia { + background: linear-gradient(145deg, + rgba(92, 142, 99, 0.06) 0%, + rgba(92, 142, 99, 0.12) 50%, + rgba(92, 142, 99, 0.08) 100%); +} + +.dye-entry h2 { + margin-top: 0; + padding-top: 0; + border-top: none; + font-family: var(--font-display); + font-size: var(--step-1); + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--text); +} + +.dye-greek { + font-family: var(--font-body); + font-style: italic; + font-size: var(--step--1); + color: var(--text-mid); + display: block; + margin-bottom: var(--space-s); +} + +.dye-swatch { + display: block; + width: 100%; + max-width: 200px; + height: 0.75rem; + margin: var(--space-s) 0; + border: 1px solid var(--rule); +} + +.swatch-aima { background: linear-gradient(to right, var(--aima), var(--aima-aged)); } +.swatch-thanatochromia { background: linear-gradient(to right, var(--thanatochromia), var(--thanatochromia-aged)); } +.swatch-aporia { background: linear-gradient(to right, var(--aporia), var(--aporia-aged)); } +.swatch-natural { background: linear-gradient(to right, var(--natural), var(--natural-aged)); } + +/* === DYE PRONUNCIATION === */ +.dye-pronunciation { + display: block; + font-family: var(--font-mono); + font-size: var(--step--2); + color: var(--text-light); + letter-spacing: 0.05em; + margin-top: var(--space-3xs); + opacity: 0.7; +} + +/* === INGREDIENT NOTE === */ +.ingredient-note { + font-style: italic; + color: var(--text-mid); + line-height: 1.8; +} + +/* === TRIAD HOVER — DECORATIVE COLOR WASH === + Purely additive: core's own .triad-1/2/3 (style.css) already render in + --accent-1/2/3 and the cycling/settling mechanics work with no skin at + all. This section only layers a full-bleed color wash on top when a + settled triad word (or a dye mark) is hovered. */ + +/* Background color shift on hover (only when settled, using :has) */ +.home-page::before, +.home-page::after, +.home > .triad-mark::after { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0; + transition: opacity 3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; + z-index: -1; +} + +.home-page::before { + background: linear-gradient(180deg, rgba(88, 21, 35, 0.15) 0%, rgba(88, 21, 35, 0.3) 50%, rgba(88, 21, 35, 0.45) 100%); +} + +.home-page::after { + background: linear-gradient(180deg, rgba(44, 27, 58, 0.18) 0%, rgba(44, 27, 58, 0.35) 50%, rgba(44, 27, 58, 0.5) 100%); +} + +.home > .triad-mark::after { + background: linear-gradient(180deg, rgba(92, 142, 99, 0.15) 0%, rgba(92, 142, 99, 0.3) 50%, rgba(92, 142, 99, 0.45) 100%); +} + +.home-page:has(.triad-mark.settled .triad-1:hover)::before { + opacity: 1; +} + +.home-page:has(.triad-mark.settled .triad-2:hover)::after { + opacity: 1; +} + +.home-page:has(.triad-mark.settled .triad-3:hover) .triad-mark::after { + opacity: 1; +} + +/* Only apply when triad is settled - this is the key to avoiding conflicts */ +.home-page { + transition: background 0.6s ease; +} + +/* Individual word hover effects - only when settled */ +.home-page:has(.triad-mark.settled .triad-1:hover) { + background: linear-gradient(135deg, + rgba(88, 21, 35, 0.08) 0%, + rgba(88, 21, 35, 0.04) 50%, + rgba(248, 243, 232, 1) 100%); +} + +.home-page:has(.triad-mark.settled .triad-2:hover) { + background: linear-gradient(135deg, + rgba(44, 27, 58, 0.1) 0%, + rgba(44, 27, 58, 0.05) 50%, + rgba(248, 243, 232, 1) 100%); +} + +.home-page:has(.triad-mark.settled .triad-3:hover) { + background: linear-gradient(135deg, + rgba(92, 142, 99, 0.08) 0%, + rgba(92, 142, 99, 0.04) 50%, + rgba(248, 243, 232, 1) 100%); +} diff --git a/static/css/style.css b/static/css/style.css index dc5a006..3bf49e3 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,15 +1,22 @@ /* * Typikon — fleet web-property substrate - * Dark academia × Japanese workwear + * Neutral core: structure, spacing, and type scale. No brand skin. * * Default fonts: EB Garamond (display), Spectral (body), IBM Plex Mono (specs) * Default scale: 1.2 ratio, 16px base, 1.625 line-height * * Consumers override the design tokens (colors, fonts, scale) by redeclaring - * the relevant `:root` custom properties in their own CSS file loaded after - * this one (config.toml [extra] consumer_css = "css/site.css", referenced from - * base.html). Do not edit the tokens here for site-specific work — fork by - * override, not by mutation. + * the relevant `:root` custom properties in their own CSS file(s), loaded + * after this one via config.toml's `[extra] consumer_css = ["css/site.css"]` + * (a list, rendered in order by templates/base.html's Consumer stylesheet + * hook comment — forkwright/typikon#55). Do not edit the tokens here for + * site-specific work — fork by override, not by mutation. + * + * --accent-1..4 below are the one exception worth calling out: they carry + * no brand opinion of their own (they default to --text-mid) and exist so + * every interactive hover/accent rule in this file resolves through a + * semantic name rather than a hard-coded hue. static/css/skins/leather.css + * is the first-party example of a skin that redeclares them. */ :root { @@ -46,23 +53,21 @@ this darker split token instead. */ --control-border: #827E77; /* 3.64:1 vs --bg, 3.40:1 vs --bg-accent */ - /* Default brand accents — ardent's iron-mordanted botanical dye palette. - Other typikon consumers should redeclare these (or rename them) in - site-specific CSS. Class hooks like .swatch-aima / .dye-entry-aima are - ardent-specific shapes that can stay unused on other sites. */ - --aima: #581523; - --aima-aged: #4A1A1A; - --thanatochromia: #2C1B3A; - --thanatochromia-aged: #382838; - --aporia: #5C8E63; - --aporia-aged: #4A7A5A; - /* WHY: --aporia is the true dye hue (used for swatches/backgrounds) and - only reaches 3.44:1 on --bg, below WCAG 2.2 AA's 4.5:1 for small text. - Interactive text uses this darker same-hue token instead; the dye - tokens above stay unmutated so swatches keep their accurate color. */ - --aporia-interactive: #4A7350; - --natural: #8B5A2B; - --natural-aged: #5C3A1F; + /* Semantic interactive-accent tokens (forkwright/typikon#55). Every + hover/accent rule below (.nav-links, a:hover's underline, .buy-btn, + .triad-1/2/3, .products-list/.journal-list, .entry-nav, .faq-anchor) + resolves through one of these four names, never a brand hue directly. + The neutral default is --text-mid: safe (9.27:1 on --bg, 8.64:1 on + --bg-accent — comfortably clears the 4.5:1 WCAG 1.4.3 floor every + consumer of these four names needs) and undifferentiated on purpose — + a site with no skin gets a working, contrast-clean, monochrome + interactive state, not a broken one. A skin (e.g. + static/css/skins/leather.css) redeclares these four to a brand + palette; core never chooses one for it. */ + --accent-1: var(--text-mid); + --accent-2: var(--text-mid); + --accent-3: var(--text-mid); + --accent-4: var(--text-mid); /* Fonts */ --font-display: 'EB Garamond', Garamond, serif; /* 16th c. philosophical text */ @@ -199,12 +204,12 @@ nav { opacity: 0; } -/* Nav links get dye colors on hover */ -.nav-links a:nth-child(1):hover { color: var(--aima); } -.nav-links a:nth-child(2):hover { color: var(--thanatochromia); } -.nav-links a:nth-child(3):hover { color: var(--aporia-interactive); } +/* Nav links get an accent color on hover — see the --accent-N tokens */ +.nav-links a:nth-child(1):hover { color: var(--accent-1); } +.nav-links a:nth-child(2):hover { color: var(--accent-2); } +.nav-links a:nth-child(3):hover { color: var(--accent-3); } .nav-links a:nth-child(4):hover { color: var(--text-mid); } -.nav-links a:nth-child(5):hover { color: var(--natural); } +.nav-links a:nth-child(5):hover { color: var(--accent-4); } .nav-links a:nth-child(6):hover { color: var(--text); } /* === MAIN === */ @@ -316,7 +321,7 @@ a { } a:hover { - text-decoration-color: var(--aima); + text-decoration-color: var(--accent-1); } /* === HOME PAGE === */ @@ -364,44 +369,10 @@ a:hover { transition: opacity 0.4s ease; } -.dye-marks { - display: flex; - gap: var(--space-s); - margin-top: var(--space-m); -} - -.mark { - width: 10px; - height: 10px; - border-radius: 50%; - transition: transform 0.3s ease; - cursor: pointer; -} - -.mark:hover { - transform: scale(1.5); -} - -.mark-aima { background: var(--aima); } -.mark-thanatochromia { background: var(--thanatochromia); } -.mark-aporia { background: var(--aporia); } - -/* Background transitions on mark hover */ -.home-page { - transition: background 0.5s ease; -} - -.home-page:has(.mark-aima:hover) { - background: linear-gradient(135deg, #F5F0E8 0%, #E8D5D0 50%, #D4B8B0 100%); -} - -.home-page:has(.mark-thanatochromia:hover) { - background: linear-gradient(135deg, #F5F0E8 0%, #DDD8E8 50%, #C8C0D8 100%); -} - -.home-page:has(.mark-aporia:hover) { - background: linear-gradient(135deg, #F5F0E8 0%, #D8E8D8 50%, #C0D8C0 100%); -} +/* .dye-marks / .mark / .mark-* and their hover-triggered home-page + background wash moved to static/css/skins/leather.css + (forkwright/typikon#55) — no core template emits these classes; they + exist purely for a skin's own content-authoring convention. */ /* Home nav below content */ .home-nav { @@ -467,7 +438,7 @@ a:hover { .products-list a:hover, .journal-list a:hover { - color: var(--aima); + color: var(--accent-1); } .product-name, @@ -657,7 +628,7 @@ th { } .buy-btn:hover { - background: var(--aima); + background: var(--accent-1); } /* Specs line */ @@ -671,77 +642,9 @@ th { border-top: 1px solid var(--rule); } -/* === DYES === */ -.dye-entry { - margin: var(--space-l) 0; - padding: var(--space-m); - border: 1px solid var(--rule); - transition: background 0.3s ease; -} - -/* Each dye entry gets its own color background */ -.dye-entry-aima { - background: linear-gradient(145deg, - rgba(88, 21, 35, 0.06) 0%, - rgba(88, 21, 35, 0.12) 50%, - rgba(88, 21, 35, 0.08) 100%); -} - -.dye-entry-thanatochromia { - background: linear-gradient(145deg, - rgba(44, 27, 58, 0.06) 0%, - rgba(44, 27, 58, 0.12) 50%, - rgba(44, 27, 58, 0.08) 100%); -} - -.dye-entry-natural { - background: linear-gradient(145deg, - rgba(139, 90, 43, 0.06) 0%, - rgba(139, 90, 43, 0.12) 50%, - rgba(139, 90, 43, 0.08) 100%); -} - -.dye-entry-aporia { - background: linear-gradient(145deg, - rgba(92, 142, 99, 0.06) 0%, - rgba(92, 142, 99, 0.12) 50%, - rgba(92, 142, 99, 0.08) 100%); -} - -.dye-entry h2 { - margin-top: 0; - padding-top: 0; - border-top: none; - font-family: var(--font-display); - font-size: var(--step-1); - font-weight: 400; - text-transform: none; - letter-spacing: 0; - color: var(--text); -} - -.dye-greek { - font-family: var(--font-body); - font-style: italic; - font-size: var(--step--1); - color: var(--text-mid); - display: block; - margin-bottom: var(--space-s); -} - -.dye-swatch { - display: block; - width: 100%; - max-width: 200px; - height: 0.75rem; - margin: var(--space-s) 0; - border: 1px solid var(--rule); -} - -.swatch-aima { background: linear-gradient(to right, var(--aima), var(--aima-aged)); } -.swatch-thanatochromia { background: linear-gradient(to right, var(--thanatochromia), var(--thanatochromia-aged)); } -.swatch-aporia { background: linear-gradient(to right, var(--aporia), var(--aporia-aged)); } -.swatch-natural { background: linear-gradient(to right, var(--natural), var(--natural-aged)); } +/* .dye-entry / .dye-entry-* / .dye-greek / .dye-swatch / .swatch-* moved + to static/css/skins/leather.css (forkwright/typikon#55) — dye-specific + content-authoring vocabulary, not consumed by any core template. */ /* === FOOTER === */ footer { @@ -891,16 +794,8 @@ footer p { color: var(--text); } -/* === DYE PRONUNCIATION === */ -.dye-pronunciation { - display: block; - font-family: var(--font-mono); - font-size: var(--step--2); - color: var(--text-light); - letter-spacing: 0.05em; - margin-top: var(--space-3xs); - opacity: 0.7; -} +/* .dye-pronunciation moved to static/css/skins/leather.css + (forkwright/typikon#55). */ /* === LAUNCH NOTIFICATION === */ .launch-note { @@ -996,12 +891,8 @@ main hr { margin-top: var(--space-xs); } -/* === INGREDIENT NOTE === */ -.ingredient-note { - font-style: italic; - color: var(--text-mid); - line-height: 1.8; -} +/* .ingredient-note moved to static/css/skins/leather.css + (forkwright/typikon#55). */ /* === TRIAD MARK (HOME) === */ .triad-mark { @@ -1113,50 +1004,15 @@ main hr { opacity: 1; } -/* Colors */ -.triad-1 { color: var(--aima); } -.triad-2 { color: var(--thanatochromia); } -.triad-3 { color: var(--aporia-interactive); } - -/* Background color shift on hover (only when settled, using :has) */ -.home-page::before, -.home-page::after, -.home > .triad-mark::after { - content: ''; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - opacity: 0; - transition: opacity 3s cubic-bezier(0.4, 0, 0.2, 1); - pointer-events: none; - z-index: -1; -} - -.home-page::before { - background: linear-gradient(180deg, rgba(88, 21, 35, 0.15) 0%, rgba(88, 21, 35, 0.3) 50%, rgba(88, 21, 35, 0.45) 100%); -} - -.home-page::after { - background: linear-gradient(180deg, rgba(44, 27, 58, 0.18) 0%, rgba(44, 27, 58, 0.35) 50%, rgba(44, 27, 58, 0.5) 100%); -} - -.home > .triad-mark::after { - background: linear-gradient(180deg, rgba(92, 142, 99, 0.15) 0%, rgba(92, 142, 99, 0.3) 50%, rgba(92, 142, 99, 0.45) 100%); -} - -.home-page:has(.triad-mark.settled .triad-1:hover)::before { - opacity: 1; -} - -.home-page:has(.triad-mark.settled .triad-2:hover)::after { - opacity: 1; -} +/* Colors — see the --accent-N tokens at the top of this file */ +.triad-1 { color: var(--accent-1); } +.triad-2 { color: var(--accent-2); } +.triad-3 { color: var(--accent-3); } -.home-page:has(.triad-mark.settled .triad-3:hover) .triad-mark::after { - opacity: 1; -} +/* The decorative full-bleed color-wash-on-hover overlay (originally here) + moved to static/css/skins/leather.css (forkwright/typikon#55) — purely + additive on top of the cycling/settling mechanics above, which work + with no skin loaded at all. */ /* === FOOTER IMPROVEMENTS === */ .footer-brand { @@ -1220,33 +1076,9 @@ main hr { opacity: 0.8; } -/* === TRIAD HOVER BACKGROUND EFFECTS === */ -/* Only apply when triad is settled - this is the key to avoiding conflicts */ -.home-page { - transition: background 0.6s ease; -} - -/* Individual word hover effects - only when settled */ -.home-page:has(.triad-mark.settled .triad-1:hover) { - background: linear-gradient(135deg, - rgba(88, 21, 35, 0.08) 0%, - rgba(88, 21, 35, 0.04) 50%, - rgba(248, 243, 232, 1) 100%); -} - -.home-page:has(.triad-mark.settled .triad-2:hover) { - background: linear-gradient(135deg, - rgba(44, 27, 58, 0.1) 0%, - rgba(44, 27, 58, 0.05) 50%, - rgba(248, 243, 232, 1) 100%); -} - -.home-page:has(.triad-mark.settled .triad-3:hover) { - background: linear-gradient(135deg, - rgba(92, 142, 99, 0.08) 0%, - rgba(92, 142, 99, 0.04) 50%, - rgba(248, 243, 232, 1) 100%); -} +/* The remaining triad-hover background-gradient effects (originally here) + also moved to static/css/skins/leather.css alongside the overlay above + (forkwright/typikon#55) — same reasoning. */ /* Accessibility */ .sr-only { @@ -1294,7 +1126,7 @@ main hr { } .entry-nav a:hover { - color: var(--aima); + color: var(--accent-1); } .entry-nav a[rel="next"] { @@ -1361,8 +1193,8 @@ main hr { } .faq-anchor:hover { - color: var(--aima); - border-bottom: 1px solid var(--aima); + color: var(--accent-1); + border-bottom: 1px solid var(--accent-1); } .faq-answer { diff --git a/templates/base.html b/templates/base.html index 22dbfe6..07b96c9 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,6 +6,21 @@ base.html does not access `page.*` or `section.*` directly so it works in every rendering context. + Consumer design/extension surface (forkwright/typikon#55) — a consumer + never needs to shadow (copy) this file to add its own identity: + - styles: config.extra.consumer_css (list, config-driven) plus the + {% block styles %}{% endblock %} slot (template-driven) + - head: {% block head_extra %}{% endblock %} + - nav: {% block nav %}{% endblock %} (default: partials/nav.html) + - footer: {% block footer %}{% endblock %} (default: partials/footer.html) + - structured data: {% block ld_json %}{% endblock %} (default: Organization) + - scripts: {% block scripts %}{% endblock %} + - composition: {% block content %}{% endblock %}, plus title/description/ + og_title/og_description/og_type/og_image/body_class + A child template overrides only the block(s) it needs; every other block + keeps rendering the default. See index.html for an example that overrides + several at once. + WHY the assert import below (forkwright/typikon#92): Tera resolves a macro namespace by which TEMPLATE an include tag is textually written in, not by the included partial's own imports, once that block is @@ -84,6 +99,20 @@ + {# Consumer stylesheet hook (forkwright/typikon#55): config.extra.consumer_css + is a list of paths (e.g. ["css/skins/leather.css", "css/site.css"]), + each rendered as a here, in order, after core's own style.css — + so a consumer's :root token overrides and any skin classes win the + cascade without forking this file. bin/typikon-check-assets verifies + every declared path resolves to a real file in the built site. + {% block styles %}{% endblock styles %} is the template-level + counterpart: a specific page/section template can add a stylesheet of + its own the same way, without touching base.html either. #} + {%- for href in config.extra.consumer_css | default(value=[]) %} + + {%- endfor %} + {% block styles %}{% endblock styles %} + {# Default JSON-LD: Organization on every page. Consumer page templates override this block to layer on Product/Article/BreadcrumbList. #} {% block ld_json %} diff --git a/theme.toml b/theme.toml index 1d76cca..d1cc328 100644 --- a/theme.toml +++ b/theme.toml @@ -15,7 +15,9 @@ author = "Cody Kickertz" homepage = "https://github.com/forkwright/typikon" repo = "https://github.com/forkwright/typikon" -# typikon does not provide a config.toml override surface yet. -# Consumers configure brand-specific values in their own config.toml [extra] block. +# Consumers configure brand-specific values in their own config.toml [extra] block, +# including [extra] consumer_css = ["css/site.css"] (docs/AGENTIC.md's design +# extension surface, forkwright/typikon#55) — a list of stylesheet paths loaded +# after core's own static/css/style.css. # When primitives need parameterization, schema goes in schemas/, default goes here. [extra]