From fd1db6b3080579c79224b4a4639ce60cd5374925 Mon Sep 17 00:00:00 2001 From: forkwright Date: Mon, 17 Aug 2026 13:20:36 -0500 Subject: [PATCH] feat(templates,css,ci): expose a real consumer design API, split Leather's skin out of core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit static/css/style.css documented a `consumer_css` stylesheet hook that templates/base.html never wired — the extension path was fictional, and both live consumers ended up shadowing base.html (and everything that extends it) to get their own stylesheet in at all. Wire the real thing instead: - templates/base.html renders `config.extra.consumer_css` (a list) as tags after core's own style.css, plus a `{% block styles %}` slot for template-level injection. base.html's header comment now documents the full block surface (styles/head/nav/footer/ld_json/ scripts/content) that was already there but undocumented. - bin/typikon-check-assets validates every consumer_css entry resolves to a real built file, the same way it already does for favicon_path/ logo_path/og_image (confirmed Zola's get_url() does NOT catch a missing one on its own). Split Leather's skin out of core: - Every hard-coded dye color in style.css's interactive-state CSS (.nav-links hover, a:hover's underline, .buy-btn, .triad-1/2/3, .products-list/.journal-list, .entry-nav, .faq-anchor) now resolves through four neutral --accent-1..4 tokens (default: --text-mid, 9.27:1 on --bg) instead of a brand hue directly. - static/css/skins/leather.css is the first-party skin: it carries the moved dye tokens, the .dye-entry-*/.swatch-*/.dye-marks content classes (unused by any core template), and the decorative hover-wash effects — moved verbatim, nothing rewritten. It maps --accent-1..4 to the dye palette, preserving the #64 WCAG fix (--accent-3 stays --aporia-interactive, never raw --aporia) one hop deeper. - ci/contrast.py's parse_root_tokens now merges every :root{} block in a concatenated source (not just the first) and resolves var()-alias declarations, not just literal hex — needed for a skin's `--accent-3: var(--aporia-interactive);` to resolve at all. - ci/check-interactive-contrast.py now scans core plus this repo's own first-party skins (FIRST_PARTY_SKINS), so the exact WCAG protection #64 established doesn't go blind the moment its token moves out of style.css. Does not extend to arbitrary consumer CSS — MATRIX stays hand-curated over typikon's own selectors, same scope as before. - ci/check-interactive-contrast-selftest.py's #64 mutation now targets the skin's --accent-3 mapping (where the regression actually lives post-split) instead of core, and restores/verifies both files. examples/sample-shop opts into the shipped leather skin (and a new content/dyes.md exercising its .dye-entry-*/.swatch-*/.dye-marks classes); examples/sample-blog opts into its own consumer_css palette (different accent hues, no skin). Both render through the unmodified core templates — neither shadows base.html or any other template. docs/AGENTIC.md and theme.toml no longer describe the fictional hook; they describe what's actually wired. Left for follow-up: the fictional hook's discovery traces to ardent-tools-site's 14+-template shadow, which this PR does not un-shadow (both live consumers pin an old commit and won't see this until their pin bumps — protective, not permission to break them). Bumping that pin and folding ardent-tools-site's shadowed templates back onto the new block surface is separate, consumer-side work. Closes #55 --- bin/typikon-check-assets | 74 ++++-- ci/check-interactive-contrast-selftest.py | 62 +++-- ci/check-interactive-contrast.py | 39 ++- ci/contrast.py | 43 +++- docs/AGENTIC.md | 10 +- examples/sample-blog/config.toml | 5 +- examples/sample-blog/content/_index.md | 5 + examples/sample-blog/static/css/site.css | 17 ++ examples/sample-shop/config.toml | 6 +- examples/sample-shop/content/_index.md | 5 + examples/sample-shop/content/dyes.md | 40 +++ static/css/skins/leather.css | 249 +++++++++++++++++++ static/css/style.css | 288 +++++----------------- templates/base.html | 29 +++ theme.toml | 6 +- 15 files changed, 583 insertions(+), 295 deletions(-) create mode 100644 examples/sample-blog/static/css/site.css create mode 100644 examples/sample-shop/content/dyes.md create mode 100644 static/css/skins/leather.css 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]