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]