#!/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 ; 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()