feat: add shared versioned Hope palette tokens

This commit is contained in:
snowspeeder committed 2026-10-03 08:36:28 -04:00
1 parent 437745bcca
commit 985c812a75
6 files changed
+1663

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Generate deterministic web/Swift theme artifacts; no third-party dependencies."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC = ROOT / "design/hope-palette.v1.json"
OUTPUTS = {"css": ROOT / "web/hope-themes.css", "swift": ROOT / "generated/HopeTheme.generated.swift"}
def rgb(value):
"""Normalized sRGB; decimal channels preserve the native alert exactly."""
if isinstance(value, str) and re.fullmatch(r"#[0-9A-F]{6}", value):
return tuple(int(value[i:i + 2], 16) / 255 for i in (1, 3, 5))
if isinstance(value, list) and len(value) == 3 and all(
isinstance(c, (int, float)) and not isinstance(c, bool) and 0 <= c <= 1 for c in value
):
return tuple(value)
raise ValueError(f"Invalid opaque sRGB value: {value!r}")
def resolve(spec, palette, appearance):
colors = {role: rgb(value) for role, value in spec["palettes"][palette][appearance].items()}
for role, reference in spec["sharedRoles"][appearance].items():
colors[role] = colors[reference[1:]] if reference.startswith("$") else rgb(spec["primitives"][reference])
for role, derivation in spec["derivedRoles"].items():
fg, bg, alpha = colors[derivation["foreground"]], colors[derivation["background"]], derivation["alpha"]
colors[role] = tuple(f * alpha + b * (1 - alpha) for f, b in zip(fg, bg))
return colors
def luminance(color):
channels = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in color]
return sum(c * weight for c, weight in zip(channels, (0.2126, 0.7152, 0.0722)))
def contrast(foreground, background):
a, b = sorted((luminance(foreground), luminance(background)))
return (b + 0.05) / (a + 0.05)
def contrast_checks(spec):
results = []
for palette in spec["palettes"]:
for appearance in ("light", "dark"):
colors = resolve(spec, palette, appearance)
pairs = [(fg, bg, 4.5) for fg in ("ink", "quiet", "accent", "alert")
for bg in ("ground", "surface", "personaSurface")]
pairs += [("ink", "selectedWash", 4.5), ("quiet", "selectedWash", 4.5),
("alert", "errorWash", 4.5), ("onAccent", "accent", 4.5),
("onAlert", "alert", 4.5)]
pairs += [(fg, bg, 3.0) for fg in ("focus", "controlBorder")
for bg in ("ground", "surface", "personaSurface")]
for fg, bg, minimum in pairs:
results.append({"palette": palette, "appearance": appearance, "foreground": fg,
"background": bg, "ratio": contrast(colors[fg], colors[bg]), "minimum": minimum})
return results
def validate(spec):
if spec["schemaVersion"] != 1 or not re.fullmatch(r"\d+\.\d+\.\d+", spec["version"]):
raise ValueError("Unsupported schema or invalid semantic version")
if spec["defaultPalette"] not in spec["palettes"] or spec["defaultAppearance"] != "system":
raise ValueError("Invalid theme defaults")
required = set(spec["roles"])
for palette, data in spec["palettes"].items():
if not re.fullmatch(r"[a-z][a-z0-9-]*", palette) or not data["label"]:
raise ValueError("Invalid palette identifier/label")
for appearance in ("light", "dark"):
if set(data[appearance]) | set(spec["sharedRoles"][appearance]) != required:
raise ValueError(f"Incomplete semantic roles: {palette}/{appearance}")
resolve(spec, palette, appearance)
for derivation in spec["derivedRoles"].values():
if not 0 <= derivation["alpha"] <= 1:
raise ValueError("Invalid derived opacity")
if not set(spec["components"].values()) <= required | set(spec["derivedRoles"]):
raise ValueError("Unknown component role")
failures = [row for row in contrast_checks(spec) if row["ratio"] < row["minimum"]]
if failures:
raise ValueError("Contrast verification failed: " + json.dumps(failures))
def kebab(value):
return re.sub(r"[A-Z]", lambda match: "-" + match[0].lower(), value)
def number(value):
return repr(float(value)) if not float(value).is_integer() else str(int(value))
def css_color(color):
return "rgb(" + " ".join(number(c * 255) for c in color) + ")"
def css_block(spec, palette, appearance, selector):
lines = [selector + " {", f" color-scheme: {appearance};"]
for role, color in resolve(spec, palette, appearance).items():
lines.append(f" --hope-primitive-{kebab(role)}: {css_color(color)};")
lines.append(f" --hope-{kebab(role)}: var(--hope-primitive-{kebab(role)});")
# Opal's native API is retained as an alias, not a new meaning for other palettes.
lines += [" --hope-rosewood: var(--hope-accent);", " --hope-on-rosewood: var(--hope-on-accent);"]
for component, role in spec["components"].items():
lines.append(f" --hope-{kebab(component)}: var(--hope-{kebab(role)});")
return "\n".join(lines + ["}"])
def render_css(spec, digest):
chunks = [f"/* Generated from design/hope-palette.v1.json v{spec['version']}; SHA256 {digest}.\n * DO NOT EDIT. python3 scripts/generate_hope_themes.py\n * Set data-palette and data-appearance on <html>; missing/unknown values fall back to Opal/system.\n */"]
default = spec["defaultPalette"]
# Root is also the fallback for stale/unknown persisted palette or appearance IDs.
chunks.append(css_block(spec, default, "light", ":root"))
for palette in spec["palettes"]:
chunks.append(css_block(spec, palette, "light", f':root[data-palette="{palette}"]'))
system_blocks = [css_block(spec, default, "dark", ':root:not([data-appearance="light"]):not([data-appearance="dark"])')]
for palette in spec["palettes"]:
system_blocks.append(css_block(spec, palette, "dark", f':root[data-palette="{palette}"]:not([data-appearance="light"]):not([data-appearance="dark"])'))
chunks.append("@media (prefers-color-scheme: dark) {\n" + "\n".join(system_blocks) + "\n}")
for appearance in ("light", "dark"):
chunks.append(css_block(spec, default, appearance, f':root[data-appearance="{appearance}"]'))
for palette in spec["palettes"]:
chunks.append(css_block(spec, palette, appearance, f':root[data-palette="{palette}"][data-appearance="{appearance}"]'))
return "\n\n".join(chunks) + "\n"
def swift_color(color):
return "HopeRGB(" + ", ".join(number(c) for c in color) + ")"
def render_swift(spec, digest):
lines = [f"// Generated from design/hope-palette.v1.json v{spec['version']}; SHA256 {digest}.",
"// DO NOT EDIT. python3 scripts/generate_hope_themes.py", "import SwiftUI", "import UIKit", "",
"struct HopeRGB: Sendable {", " let red: Double", " let green: Double", " let blue: Double",
" init(_ red: Double, _ green: Double, _ blue: Double) {",
" self.red = red; self.green = green; self.blue = blue", " }",
" var uiColor: UIColor { UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1) }", "}", "",
"enum HopeAppearance: String, CaseIterable, Sendable {", " case system, light, dark", "}", "",
"enum HopePalette: String, CaseIterable, Sendable {"]
for palette in spec["palettes"]:
lines.append(f" case {palette}")
lines += [f" static let defaultPalette: HopePalette = .{spec['defaultPalette']}", " var label: String {", " switch self {"]
for palette, data in spec["palettes"].items():
lines.append(f" case .{palette}: return {json.dumps(data['label'])}")
lines += [" }", " }", "}", "", "enum HopeThemeRole: String, CaseIterable, Sendable {"]
for role in list(spec["roles"]) + list(spec["derivedRoles"]):
lines.append(f" case {role}")
lines += ["}", "", "enum HopeTheme {", f" static let version = {json.dumps(spec['version'])}",
" static func rgb(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette, dark: Bool) -> HopeRGB {", " switch palette {"]
for palette in spec["palettes"]:
lines += [f" case .{palette}:", " switch role {"]
light, dark = resolve(spec, palette, "light"), resolve(spec, palette, "dark")
for role in light:
lines.append(f" case .{role}: return dark ? {swift_color(dark[role])} : {swift_color(light[role])}")
lines += [" }"]
lines += [" }", " }", "", " static func color(_ role: HopeThemeRole, palette: HopePalette = .defaultPalette,", " appearance: HopeAppearance = .system) -> Color {",
" let light = rgb(role, palette: palette, dark: false).uiColor",
" let dark = rgb(role, palette: palette, dark: true).uiColor",
" switch appearance {", " case .light: return Color(uiColor: light)", " case .dark: return Color(uiColor: dark)",
" case .system:", " return Color(uiColor: UIColor { traits in", " traits.userInterfaceStyle == .dark ? dark : light", " })", " }", " }", "}"]
return "\n".join(lines) + "\n"
def artifacts(spec, source_bytes):
validate(spec)
digest = hashlib.sha256(source_bytes).hexdigest()
return {"css": render_css(spec, digest), "swift": render_swift(spec, digest)}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="Check committed outputs without rewriting")
parser.add_argument("--contrast", action="store_true", help="Print every verified contrast pair")
args = parser.parse_args()
source = SPEC.read_bytes()
spec = json.loads(source)
outputs = artifacts(spec, source)
if args.contrast:
for row in contrast_checks(spec):
print(f"{row['palette']}/{row['appearance']} {row['foreground']}/{row['background']}: {row['ratio']:.3f}:1 >= {row['minimum']}:1")
stale = []
for key, path in OUTPUTS.items():
if args.check:
if not path.exists() or path.read_text() != outputs[key]:
stale.append(str(path.relative_to(ROOT)))
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(outputs[key])
if stale:
parser.exit(1, "Stale theme artifacts: " + ", ".join(stale) + "\n")
minimum = min(row['ratio'] for row in contrast_checks(spec) if row['minimum'] == 4.5)
print(f"Hope themes v{spec['version']}: {len(spec['palettes'])} palettes, light/dark; minimum text contrast {minimum:.3f}:1; artifacts {'verified' if args.check else 'generated'}.")
if __name__ == "__main__":
main()