| 1 | #!/usr/bin/env python3 |
| 2 | """Generate the portable GPUI design artifact. No sibling checkout is needed. |
| 3 | Edit tokens.json only in the codewhale-design repository. Consumers vendor the whole folder. |
| 4 | """ |
| 5 | import argparse |
| 6 | import hashlib |
| 7 | import json |
| 8 | from pathlib import Path |
| 9 | |
| 10 | ROOT = Path(__file__).resolve().parent |
| 11 | |
| 12 | # WCAG 2.x contrast every palette must hold, in both modes, before anything is |
| 13 | # generated: text roles at 4.5:1 and control edges at 3:1 on each ground a |
| 14 | # role can sit on, including a hovered or selected row. |
| 15 | GROUNDS = ["background", "surface", "sidebar", "hover", "selected"] |
| 16 | TEXT = ["foreground", "muted_foreground", "primary", "live", "attention", "danger"] |
| 17 | # Secondary text carries labels, metadata and status words people must read at |
| 18 | # small sizes, so it holds 5.5:1 on every ground, above the AA floor. |
| 19 | TEXT_FLOORS = {"muted_foreground": 5.5} |
| 20 | EDGES = ["border_strong"] |
| 21 | FILLED = [("primary_foreground", "primary")] |
| 22 | |
| 23 | def luminance(hex6): |
| 24 | channels = [int(hex6[i:i + 2], 16) / 255 for i in (0, 2, 4)] |
| 25 | channels = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels] |
| 26 | return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] |
| 27 | |
| 28 | def contrast(a, b): |
| 29 | hi, lo = sorted([luminance(a), luminance(b)], reverse=True) |
| 30 | return (hi + 0.05) / (lo + 0.05) |
| 31 | |
| 32 | def contrast_violations(colors): |
| 33 | pairs = [(fg, bg, TEXT_FLOORS.get(fg, 4.5)) for fg in TEXT for bg in GROUNDS] |
| 34 | pairs += [(edge, bg, 3.0) for edge in EDGES for bg in GROUNDS] |
| 35 | pairs += [(fg, bg, 4.5) for fg, bg in FILLED] |
| 36 | found = [] |
| 37 | for mode, palette in colors.items(): |
| 38 | for fg, bg, floor in pairs: |
| 39 | ratio = contrast(palette[fg], palette[bg]) |
| 40 | if ratio < floor: |
| 41 | found.append(f"{mode} {fg} on {bg} is {ratio:.2f}:1, needs {floor}:1") |
| 42 | return found |
| 43 | |
| 44 | def render(): |
| 45 | source = (ROOT / "tokens.json").read_bytes() |
| 46 | data = json.loads(source) |
| 47 | for name in ["selection_opacity", "primary_hover_opacity"]: |
| 48 | value = data[name] |
| 49 | if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 1: |
| 50 | raise ValueError(f"{name} must be a number between 0 and 1") |
| 51 | digest = hashlib.sha256(source).hexdigest() |
| 52 | header = f"Generated from Codewhale GPUI design {data['version']}; sha256 {digest}. Do not edit." |
| 53 | keys = list(data["colors"]["dark"]) |
| 54 | assert set(keys) == set(data["colors"]["light"]) |
| 55 | for palette in data["colors"].values(): |
| 56 | assert all(len(v) == 6 and all(c in "0123456789abcdef" for c in v) for v in palette.values()) |
| 57 | violations = contrast_violations(data["colors"]) |
| 58 | if violations: |
| 59 | raise ValueError("Contrast below WCAG AA:\n " + "\n ".join(violations)) |
| 60 | rust = [f"// {header}", "#![allow(dead_code)]", "", f'pub const VERSION: &str = "{data["version"]}";', |
| 61 | "#[derive(Clone, Copy, Debug)]", "pub struct Colors {"] |
| 62 | rust += [f" pub {k}: u32," for k in keys] |
| 63 | rust += ["}"] |
| 64 | for name, palette in data["colors"].items(): |
| 65 | rust += [f"pub const {name.upper()}: Colors = Colors {{"] |
| 66 | rust += [f" {k}: 0x{v}," for k, v in palette.items()] |
| 67 | rust += ["};"] |
| 68 | rust += ["pub fn colors(dark: bool) -> Colors {", " if dark { DARK } else { LIGHT }", "}"] |
| 69 | rust += [f'pub const FONT_FAMILY: &str = "{data["typography"]["family"]}";', |
| 70 | "pub const FONT_FALLBACKS: &[&str] = &["] |
| 71 | rust += [f" {json.dumps(v)}," for v in data["typography"]["fallbacks"]] |
| 72 | rust += ["];"] |
| 73 | for section in ["spacing", "radius", "focus", "icons", "motion"]: |
| 74 | for name, value in data[section].items(): |
| 75 | rust += [f"pub const {section.upper()}_{name.upper()}: f32 = {float(value)};"] |
| 76 | for name in ["selection_opacity", "primary_hover_opacity"]: |
| 77 | rust += [f"pub const {name.upper()}: f32 = {float(data[name])};"] |
| 78 | rust += [f'pub const MONO_PX: f32 = {float(data["typography"]["mono_px"])};'] |
| 79 | for name in ["body_px", "caption_px", "prose_px", "heading_px", "title_px"]: |
| 80 | if name in data["typography"]: |
| 81 | rust += [f'pub const TYPE_{name.upper()}: f32 = {float(data["typography"][name])};'] |
| 82 | # Existing web names remain aliases, so every current component migrates together. |
| 83 | aliases = {"outside":"sidebar","surface":"background","panel":"surface","text":"foreground", |
| 84 | "muted":"muted_foreground","line":"border","accent":"primary","button":"primary", |
| 85 | "button-text":"primary_foreground","attention":"attention","hover":"hover","selected":"selected", |
| 86 | "live":"live","danger":"danger","border-strong":"border_strong"} |
| 87 | css = [f"/* {header} */"] |
| 88 | for mode, palette in data["colors"].items(): |
| 89 | css += [":root {" if mode == "dark" else '[data-theme="light"] {'] |
| 90 | css += [f" --{alias}: #{palette[key]};" for alias, key in aliases.items()] |
| 91 | css += [f' --selection: #{palette["primary"]}{round(data["selection_opacity"] * 255):02x};', |
| 92 | f" color-scheme: {mode};", "}"] |
| 93 | css += [":root {"] |
| 94 | for section in ["spacing", "radius", "focus"]: |
| 95 | css += [f" --{section}-{name}: {value}px;" for name, value in data[section].items()] |
| 96 | css += [f' --icon-stroke: {data["icons"]["stroke"]};', |
| 97 | f' --font-family: {json.dumps(data["typography"]["family"], ensure_ascii=False)};', |
| 98 | f' --font-fallbacks: {", ".join(json.dumps(v, ensure_ascii=False) for v in data["typography"]["fallbacks"])};', |
| 99 | f' --font-body-size: {data["typography"]["body_px"]}px;', |
| 100 | f' --font-code-size: {data["typography"]["mono_px"]}px;'] |
| 101 | for name in ["caption_px", "prose_px", "heading_px", "title_px"]: |
| 102 | if name in data["typography"]: |
| 103 | css += [f' --font-{name[:-3]}-size: {data["typography"][name]}px;'] |
| 104 | css += ["}"] |
| 105 | return {"tokens.rs":"\n".join(rust) + "\n", "tokens.css":"\n".join(css) + "\n"} |
| 106 | |
| 107 | def main(): |
| 108 | parser = argparse.ArgumentParser(description=__doc__) |
| 109 | parser.add_argument("--check", action="store_true") |
| 110 | args = parser.parse_args() |
| 111 | stale = [] |
| 112 | for name, content in render().items(): |
| 113 | path = ROOT / name |
| 114 | if args.check: |
| 115 | if not path.exists() or path.read_text() != content: |
| 116 | stale.append(name) |
| 117 | else: |
| 118 | path.write_text(content) |
| 119 | if stale: |
| 120 | raise SystemExit("Stale GPUI design artifact: " + ", ".join(stale)) |
| 121 | print("GPUI design artifact verified" if args.check else "GPUI design artifact generated") |
| 122 | |
| 123 | if __name__ == "__main__": |
| 124 | main() |
| 125 |