202 lines
11 KiB
Python
202 lines
11 KiB
Python
#!/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",
|
|
"ios": ROOT / "ios/Sources/Chat/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()
|
|
swift = render_swift(spec, digest)
|
|
return {"css": render_css(spec, digest), "swift": swift, "ios": swift}
|
|
|
|
|
|
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()
|