| 1 | #!/usr/bin/env python3 |
| 2 | """Export the Codewhale palettes to the other Codewhale clients. |
| 3 | |
| 4 | `crates/palette/src/tokens.rs` is the single source for the product colors. |
| 5 | This script parses its `WHALE_*_RGB`, `LIGHT_*_RGB`, `SHORELINE_*_RGB`, and |
| 6 | `SHORELINE_LIGHT_*_RGB` consts (aliases included) and writes the same values |
| 7 | as CSS custom properties so the web app stops hand-copying hexes. The |
| 8 | Shoreline set is the Shoreline redesign's dark + light pair; `--whale-*` and |
| 9 | `--light-*` stay until the components that use them migrate. |
| 10 | |
| 11 | Target: <repo>/web/app/tokens.css. This script writes nothing outside this |
| 12 | repository. |
| 13 | |
| 14 | Usage: |
| 15 | scripts/export-design-tokens.py # write |
| 16 | scripts/export-design-tokens.py --check # exit 1 if any target is stale |
| 17 | """ |
| 18 | |
| 19 | from __future__ import annotations |
| 20 | |
| 21 | import argparse |
| 22 | import re |
| 23 | import sys |
| 24 | from pathlib import Path |
| 25 | |
| 26 | REPO = Path(__file__).resolve().parent.parent |
| 27 | TOKENS_RS = REPO / "crates/palette/src/tokens.rs" |
| 28 | SOURCE_LABEL = "crates/palette/src/tokens.rs" |
| 29 | |
| 30 | CONST_RE = re.compile( |
| 31 | r"^pub const ((?:SHORELINE_LIGHT|SHORELINE|WHALE|LIGHT)_[A-Z0-9_]+)_RGB: \(u8, u8, u8\) = " |
| 32 | r"(?:\((\d+), (\d+), (\d+)\)|((?:SHORELINE_LIGHT|SHORELINE|WHALE|LIGHT)_[A-Z0-9_]+)_RGB);", |
| 33 | re.MULTILINE, |
| 34 | ) |
| 35 | |
| 36 | |
| 37 | |
| 38 | def parse_tokens(text: str) -> list[tuple[str, tuple[int, int, int] | str]]: |
| 39 | """Return [(NAME, (r, g, b) | alias-NAME)] in source order.""" |
| 40 | tokens: list[tuple[str, tuple[int, int, int] | str]] = [] |
| 41 | known: set[str] = set() |
| 42 | for m in CONST_RE.finditer(text): |
| 43 | name = m.group(1) |
| 44 | if m.group(5) is not None: |
| 45 | target = m.group(5) |
| 46 | if target not in known: |
| 47 | raise SystemExit(f"{name} aliases unknown token {target}") |
| 48 | tokens.append((name, target)) |
| 49 | else: |
| 50 | tokens.append((name, (int(m.group(2)), int(m.group(3)), int(m.group(4))))) |
| 51 | known.add(name) |
| 52 | if not tokens: |
| 53 | raise SystemExit(f"no palette RGB consts found in {TOKENS_RS}") |
| 54 | return tokens |
| 55 | |
| 56 | |
| 57 | def css_name(name: str) -> str: |
| 58 | """`WHALE_*` exports as `--whale-*`; the Blue Stage light preset's |
| 59 | `LIGHT_*` consts export as `--light-*` so the website's paper surface can |
| 60 | reference the same light-mode ink and border values the TUI ships. The |
| 61 | Shoreline dark pair exports as `--shoreline-*` and its light pair as |
| 62 | `--shoreline-light-*`, matching the theme the TUI and GPUI clients open |
| 63 | on.""" |
| 64 | if name.startswith("SHORELINE_LIGHT_"): |
| 65 | return "--shoreline-light-" + name.removeprefix("SHORELINE_LIGHT_").lower().replace( |
| 66 | "_", "-" |
| 67 | ) |
| 68 | if name.startswith("SHORELINE_"): |
| 69 | return "--shoreline-" + name.removeprefix("SHORELINE_").lower().replace("_", "-") |
| 70 | if name.startswith("LIGHT_"): |
| 71 | return "--light-" + name.removeprefix("LIGHT_").lower().replace("_", "-") |
| 72 | return "--whale-" + name.removeprefix("WHALE_").lower().replace("_", "-") |
| 73 | |
| 74 | |
| 75 | def render_css(tokens) -> str: |
| 76 | lines = [ |
| 77 | f"/* generated from {SOURCE_LABEL} — do not edit */", |
| 78 | "/* regenerate: scripts/export-design-tokens.py (in the codewhale repo) */", |
| 79 | ":root {", |
| 80 | ] |
| 81 | for name, value in tokens: |
| 82 | prop = css_name(name) |
| 83 | if isinstance(value, str): |
| 84 | ref = css_name(value) |
| 85 | lines.append(f" {prop}: var({ref});") |
| 86 | lines.append(f" {prop}-rgb: var({ref}-rgb);") |
| 87 | else: |
| 88 | r, g, b = value |
| 89 | lines.append(f" {prop}: #{r:02x}{g:02x}{b:02x};") |
| 90 | lines.append(f" {prop}-rgb: {r} {g} {b};") |
| 91 | lines.append("}") |
| 92 | return "\n".join(lines) + "\n" |
| 93 | |
| 94 | |
| 95 | def main() -> int: |
| 96 | ap = argparse.ArgumentParser(description=__doc__) |
| 97 | ap.add_argument("--check", action="store_true", help="verify instead of write") |
| 98 | args = ap.parse_args() |
| 99 | |
| 100 | tokens = parse_tokens(TOKENS_RS.read_text(encoding="utf-8")) |
| 101 | css = render_css(tokens) |
| 102 | |
| 103 | targets: list[tuple[Path, str]] = [(REPO / "web/app/tokens.css", css)] |
| 104 | |
| 105 | stale = [] |
| 106 | for path, content in targets: |
| 107 | current = path.read_text(encoding="utf-8") if path.exists() else None |
| 108 | if current == content: |
| 109 | continue |
| 110 | if args.check: |
| 111 | stale.append(path) |
| 112 | else: |
| 113 | path.parent.mkdir(parents=True, exist_ok=True) |
| 114 | path.write_text(content, encoding="utf-8") |
| 115 | print(f"wrote {path}") |
| 116 | |
| 117 | if stale: |
| 118 | for path in stale: |
| 119 | print(f"stale: {path}", file=sys.stderr) |
| 120 | print( |
| 121 | "run scripts/export-design-tokens.py to regenerate from " |
| 122 | f"{SOURCE_LABEL}", |
| 123 | file=sys.stderr, |
| 124 | ) |
| 125 | return 1 |
| 126 | if args.check: |
| 127 | print(f"design tokens up to date ({len(targets)} file(s), {len(tokens)} tokens)") |
| 128 | return 0 |
| 129 | |
| 130 | |
| 131 | if __name__ == "__main__": |
| 132 | sys.exit(main()) |
| 133 |