返回 CodeWhale
generate.py
根目录 / vendor / codewhale-design / generate.py
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
125 lines PYTHON