| 1 | """Official theme palettes + WCAG contrast gate. |
| 2 | |
| 3 | Defines the full token set for all eight official themes and checks the same |
| 4 | pairs the Go backend validates (fg/bg, fg/chat >= 4.5; fgFaint/bg >= 3.0; |
| 5 | accentFg/accent >= 3.0). ok/warn/err are intentionally unset so they inherit |
| 6 | the base style. `write_manifests()` emits theme.json files. |
| 7 | """ |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import json |
| 11 | import os |
| 12 | import shutil |
| 13 | import sys |
| 14 | |
| 15 | HERE = os.path.dirname(__file__) |
| 16 | OUT = os.path.join(HERE, "out") |
| 17 | DEST = os.path.join(HERE, "..", "..", "desktop", "themes", "official") |
| 18 | |
| 19 | SURFACE_KEYS = ["bg", "bgSoft", "bgElev", "panel", "sidebar", "chat", "workspace", "workspaceFiles"] |
| 20 | TEXT_KEYS = ["border", "borderSoft", "fg", "fgDim", "fgFaint", "accent", "accentFg"] |
| 21 | |
| 22 | # focusX, focusY / homeOpacity / taskOpacity / overlayStrength from the plan table. |
| 23 | THEMES = { |
| 24 | "official-rose-dawn": { |
| 25 | "name": "Rose Dawn", |
| 26 | "zh": "玫瑰晨光", |
| 27 | "baseStyle": "graphite", |
| 28 | "corners": "round", |
| 29 | "bgparams": (0.72, 0.43, 1.0, 0.20, 0.68), |
| 30 | "description": "Ivory dawn light, soft roses and an original illustrated muse on the right.", |
| 31 | "light": { |
| 32 | "bg": "#FFF7F8", "bgSoft": "#FBEFF1", "bgElev": "#FFFFFF", "panel": "#FFFFFF", |
| 33 | "sidebar": "#FBECEF", "chat": "#FFFCFC", "workspace": "#FFFFFF", "workspaceFiles": "#FDF3F4", |
| 34 | "border": "#E2BCC6", "borderSoft": "#F5E3E7", |
| 35 | "fg": "#3A252C", "fgDim": "#6D4A55", "fgFaint": "#A97B87", |
| 36 | "accent": "#B43F65", "accentFg": "#FFF7F8", |
| 37 | }, |
| 38 | "dark": { |
| 39 | "bg": "#1E1419", "bgSoft": "#281B21", "bgElev": "#312329", "panel": "#2A1D24", |
| 40 | "sidebar": "#251922", "chat": "#231820", "workspace": "#312329", "workspaceFiles": "#281C22", |
| 41 | "border": "#4C3440", "borderSoft": "#3A2832", |
| 42 | "fg": "#FFF3F6", "fgDim": "#D9B3C0", "fgFaint": "#A87C8C", |
| 43 | "accent": "#E26D91", "accentFg": "#2A121D", |
| 44 | }, |
| 45 | }, |
| 46 | "official-fortune-forge": { |
| 47 | "name": "Fortune Forge", |
| 48 | "zh": "鸿运工坊", |
| 49 | "baseStyle": "amber", |
| 50 | "corners": "soft", |
| 51 | "bgparams": (0.74, 0.44, 1.0, 0.20, 0.70), |
| 52 | "description": "Warm ivory workshop with vermilion, gold and jade around an original lucky programmer.", |
| 53 | "light": { |
| 54 | "bg": "#FFF8E8", "bgSoft": "#F9EFD5", "bgElev": "#FFFDF6", "panel": "#FFFDF6", |
| 55 | "sidebar": "#F8EDD3", "chat": "#FFFBF1", "workspace": "#FFFDF6", "workspaceFiles": "#FAF1DC", |
| 56 | "border": "#D6B87E", "borderSoft": "#F2E7CD", |
| 57 | "fg": "#382116", "fgDim": "#6E4E35", "fgFaint": "#A98A68", |
| 58 | "accent": "#A92D22", "accentFg": "#FFF8E8", |
| 59 | }, |
| 60 | "dark": { |
| 61 | "bg": "#1D140D", "bgSoft": "#271C12", "bgElev": "#302417", "panel": "#291E13", |
| 62 | "sidebar": "#251B11", "chat": "#231A11", "workspace": "#302417", "workspaceFiles": "#271D13", |
| 63 | "border": "#4D3A22", "borderSoft": "#3B2C1A", |
| 64 | "fg": "#FFF2D1", "fgDim": "#DDC49C", "fgFaint": "#A9926B", |
| 65 | "accent": "#E8AD38", "accentFg": "#241606", |
| 66 | }, |
| 67 | }, |
| 68 | "official-crimson-horizon": { |
| 69 | "name": "Crimson Horizon", |
| 70 | "zh": "赤曜新城", |
| 71 | "baseStyle": "graphite", |
| 72 | "corners": "soft", |
| 73 | "bgparams": (0.75, 0.45, 0.98, 0.22, 0.66), |
| 74 | "description": "Pearl-white sky over a coral-red future city skyline. No people, pure skyline.", |
| 75 | "light": { |
| 76 | "bg": "#FFF8F7", "bgSoft": "#F9EEEC", "bgElev": "#FFFFFF", "panel": "#FFFFFF", |
| 77 | "sidebar": "#F9ECEA", "chat": "#FFFBFA", "workspace": "#FFFFFF", "workspaceFiles": "#FBF1EF", |
| 78 | "border": "#DFB3AD", "borderSoft": "#F6E4E2", |
| 79 | "fg": "#301D1D", "fgDim": "#6B4644", "fgFaint": "#A9807D", |
| 80 | "accent": "#B92B38", "accentFg": "#FFF8F7", |
| 81 | }, |
| 82 | "dark": { |
| 83 | "bg": "#190D11", "bgSoft": "#221318", "bgElev": "#2B181E", "panel": "#25141A", |
| 84 | "sidebar": "#211218", "chat": "#201116", "workspace": "#2B181E", "workspaceFiles": "#221319", |
| 85 | "border": "#582F3C", "borderSoft": "#361B24", |
| 86 | "fg": "#FFF1F2", "fgDim": "#DFB3B6", "fgFaint": "#AC7E84", |
| 87 | "accent": "#FF6772", "accentFg": "#2A0E12", |
| 88 | }, |
| 89 | }, |
| 90 | "official-sage-breeze": { |
| 91 | "name": "Sage Breeze", |
| 92 | "zh": "鼠尾草清风", |
| 93 | "baseStyle": "slate", |
| 94 | "corners": "soft", |
| 95 | "bgparams": (0.73, 0.44, 1.0, 0.20, 0.68), |
| 96 | "description": "Cream paper, sage sprigs and an original illustrated reader on the right.", |
| 97 | "light": { |
| 98 | "bg": "#F7F7EF", "bgSoft": "#EFEFE2", "bgElev": "#FCFCF6", "panel": "#FCFCF6", |
| 99 | "sidebar": "#EEEEE0", "chat": "#FAFAF4", "workspace": "#FCFCF6", "workspaceFiles": "#F1F1E5", |
| 100 | "border": "#BFC095", "borderSoft": "#E8E8D8", |
| 101 | "fg": "#26332D", "fgDim": "#4E6157", "fgFaint": "#7C8F83", |
| 102 | "accent": "#47735F", "accentFg": "#F7F7EF", |
| 103 | }, |
| 104 | "dark": { |
| 105 | "bg": "#101814", "bgSoft": "#17211C", "bgElev": "#1E2922", "panel": "#19231D", |
| 106 | "sidebar": "#161F1A", "chat": "#151E19", "workspace": "#1E2922", "workspaceFiles": "#17211C", |
| 107 | "border": "#32443A", "borderSoft": "#25322B", |
| 108 | "fg": "#EEF6F0", "fgDim": "#B7CDBF", "fgFaint": "#7E968A", |
| 109 | "accent": "#84CBA7", "accentFg": "#0E1A13", |
| 110 | }, |
| 111 | }, |
| 112 | "official-spark-notebook": { |
| 113 | "name": "Spark Notebook", |
| 114 | "zh": "灵感手账", |
| 115 | "baseStyle": "aurora", |
| 116 | "corners": "round", |
| 117 | "bgparams": (0.74, 0.46, 0.98, 0.20, 0.68), |
| 118 | "description": "Off-white notebook grid with teal, yellow and coral stationery around an original anime adult.", |
| 119 | "light": { |
| 120 | "bg": "#FFF9ED", "bgSoft": "#F9F1DE", "bgElev": "#FFFDF7", "panel": "#FFFDF7", |
| 121 | "sidebar": "#F8F0DC", "chat": "#FFFCF4", "workspace": "#FFFDF7", "workspaceFiles": "#FAF3E2", |
| 122 | "border": "#D2BC8E", "borderSoft": "#F2E9D4", |
| 123 | "fg": "#2B2F35", "fgDim": "#565D66", "fgFaint": "#848D98", |
| 124 | "accent": "#007B78", "accentFg": "#F3FBFA", |
| 125 | }, |
| 126 | "dark": { |
| 127 | "bg": "#14171A", "bgSoft": "#1B1F23", "bgElev": "#23282D", "panel": "#1D2226", |
| 128 | "sidebar": "#1A1E22", "chat": "#191D21", "workspace": "#23282D", "workspaceFiles": "#1B2024", |
| 129 | "border": "#38404A", "borderSoft": "#2A3138", |
| 130 | "fg": "#F8F5E9", "fgDim": "#C9CCBF", "fgFaint": "#8B918A", |
| 131 | "accent": "#42D1C6", "accentFg": "#08201E", |
| 132 | }, |
| 133 | }, |
| 134 | "official-violet-starlight": { |
| 135 | "name": "Violet Starlight", |
| 136 | "zh": "紫曜星夜", |
| 137 | "baseStyle": "nocturne", |
| 138 | "corners": "round", |
| 139 | "bgparams": (0.73, 0.44, 0.96, 0.18, 0.72), |
| 140 | "description": "Blue-violet starfield with glowing butterflies and an original silhouette muse.", |
| 141 | "light": { |
| 142 | "bg": "#F7F4FF", "bgSoft": "#EFEBFB", "bgElev": "#FCFAFF", "panel": "#FCFAFF", |
| 143 | "sidebar": "#EEE9FA", "chat": "#FAF8FF", "workspace": "#FCFAFF", "workspaceFiles": "#F1EDFB", |
| 144 | "border": "#CABDEC", "borderSoft": "#E8E3F7", |
| 145 | "fg": "#251F3C", "fgDim": "#544B74", "fgFaint": "#9188B3", |
| 146 | "accent": "#6242C7", "accentFg": "#F7F4FF", |
| 147 | }, |
| 148 | "dark": { |
| 149 | "bg": "#0C1022", "bgSoft": "#131834", "bgElev": "#1A2140", "panel": "#151B36", |
| 150 | "sidebar": "#121732", "chat": "#111630", "workspace": "#1A2140", "workspaceFiles": "#131834", |
| 151 | "border": "#2E3A66", "borderSoft": "#222B4E", |
| 152 | "fg": "#F4F2FF", "fgDim": "#C2BDE8", "fgFaint": "#8580B8", |
| 153 | "accent": "#9B86FF", "accentFg": "#14102E", |
| 154 | }, |
| 155 | }, |
| 156 | "official-cyan-stage": { |
| 157 | "name": "Cyan Stage", |
| 158 | "zh": "青岚舞台", |
| 159 | "baseStyle": "carbon", |
| 160 | "corners": "round", |
| 161 | "bgparams": (0.74, 0.45, 0.96, 0.18, 0.72), |
| 162 | "description": "Airy cyan future stage with light rings and an original digital performer.", |
| 163 | "light": { |
| 164 | "bg": "#F1FCFD", "bgSoft": "#E4F5F7", "bgElev": "#FAFEFE", "panel": "#FAFEFE", |
| 165 | "sidebar": "#E3F4F6", "chat": "#F6FDFE", "workspace": "#FAFEFE", "workspaceFiles": "#E8F6F8", |
| 166 | "border": "#9CCFD7", "borderSoft": "#DAEDF0", |
| 167 | "fg": "#173238", "fgDim": "#43606A", "fgFaint": "#71939D", |
| 168 | "accent": "#007C92", "accentFg": "#F1FCFD", |
| 169 | }, |
| 170 | "dark": { |
| 171 | "bg": "#07181D", "bgSoft": "#0C2229", "bgElev": "#112C34", "panel": "#0E252D", |
| 172 | "sidebar": "#0B2128", "chat": "#0A2027", "workspace": "#112C34", "workspaceFiles": "#0C232A", |
| 173 | "border": "#1F4550", "borderSoft": "#16333C", |
| 174 | "fg": "#E9FCFF", "fgDim": "#AEDBE2", "fgFaint": "#6FA5AF", |
| 175 | "accent": "#37D7E4", "accentFg": "#04222a", |
| 176 | }, |
| 177 | }, |
| 178 | "official-noir-gold": { |
| 179 | "name": "Noir Gold", |
| 180 | "zh": "黑金序曲", |
| 181 | "baseStyle": "carbon", |
| 182 | "corners": "soft", |
| 183 | "bgparams": (0.73, 0.43, 0.94, 0.18, 0.74), |
| 184 | "description": "Champagne field, black velvet curtain and gold spotlights around an original gentleman.", |
| 185 | "light": { |
| 186 | "bg": "#FCF8EE", "bgSoft": "#F6F0DF", "bgElev": "#FEFBF4", "panel": "#FEFBF4", |
| 187 | "sidebar": "#F5EFDD", "chat": "#FDFAF2", "workspace": "#FEFBF4", "workspaceFiles": "#F7F1E1", |
| 188 | "border": "#CCBE94", "borderSoft": "#EFE8D2", |
| 189 | "fg": "#2A241B", "fgDim": "#5C5340", "fgFaint": "#9A8F74", |
| 190 | "accent": "#7A5A16", "accentFg": "#FCF8EE", |
| 191 | }, |
| 192 | "dark": { |
| 193 | "bg": "#0D0B09", "bgSoft": "#15120E", "bgElev": "#1D1913", "panel": "#171410", |
| 194 | "sidebar": "#14110D", "chat": "#131009", "workspace": "#1D1913", "workspaceFiles": "#15120E", |
| 195 | "border": "#463B27", "borderSoft": "#2A2418", |
| 196 | "fg": "#F8F1DF", "fgDim": "#D6CBAE", "fgFaint": "#968C6E", |
| 197 | "accent": "#D9B45B", "accentFg": "#1D1503", |
| 198 | }, |
| 199 | }, |
| 200 | } |
| 201 | |
| 202 | |
| 203 | def _lum(hexs: str) -> float: |
| 204 | s = hexs.lstrip("#")[:6] |
| 205 | rgb = [int(s[i : i + 2], 16) / 255 for i in (0, 2, 4)] |
| 206 | |
| 207 | def lin(c): |
| 208 | return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 |
| 209 | |
| 210 | return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]) |
| 211 | |
| 212 | |
| 213 | def ratio(a: str, b: str) -> float: |
| 214 | la, lb = _lum(a), _lum(b) |
| 215 | return (max(la, lb) + 0.05) / (min(la, lb) + 0.05) |
| 216 | |
| 217 | |
| 218 | def check() -> int: |
| 219 | fails = 0 |
| 220 | for tid, spec in THEMES.items(): |
| 221 | for mode in ("light", "dark"): |
| 222 | tk = spec[mode] |
| 223 | pairs = [ |
| 224 | ("fg/bg", tk["fg"], tk["bg"], 4.5), |
| 225 | ("fg/chat", tk["fg"], tk["chat"], 4.5), |
| 226 | ("fg/panel", tk["fg"], tk["panel"], 4.5), |
| 227 | ("fgDim/bg", tk["fgDim"], tk["bg"], 3.0), |
| 228 | ("fgFaint/bg", tk["fgFaint"], tk["bg"], 3.0), |
| 229 | ("fgFaint/panel", tk["fgFaint"], tk["panel"], 3.0), |
| 230 | ("accentFg/accent", tk["accentFg"], tk["accent"], 3.0), |
| 231 | ("accent/bg", tk["accent"], tk["bg"], 3.0), |
| 232 | ("border/bg", tk["border"], tk["bg"], 1.6), |
| 233 | ] |
| 234 | for name, a, b, minimum in pairs: |
| 235 | r = ratio(a, b) |
| 236 | if r < minimum: |
| 237 | fails += 1 |
| 238 | print(f"FAIL {tid} {mode} {name}: {r:.2f} < {minimum} ({a} on {b})") |
| 239 | print("contrast gate:", "OK — zero warnings" if fails == 0 else f"{fails} failures") |
| 240 | return fails |
| 241 | |
| 242 | |
| 243 | def write_manifests(): |
| 244 | for tid, spec in THEMES.items(): |
| 245 | fx, fy, home, task, overlay = spec["bgparams"] |
| 246 | manifest = { |
| 247 | "schemaVersion": 1, |
| 248 | "id": tid, |
| 249 | "name": spec["name"], |
| 250 | "author": "Reasonix Contributors", |
| 251 | "description": spec["description"], |
| 252 | "license": "MIT", |
| 253 | "baseStyle": spec["baseStyle"], |
| 254 | "tokens": {"light": spec["light"], "dark": spec["dark"]}, |
| 255 | "recipes": {"density": "comfortable", "corners": spec["corners"]}, |
| 256 | "background": { |
| 257 | "image": "background.webp", |
| 258 | "focusX": fx, |
| 259 | "focusY": fy, |
| 260 | "safeArea": "left", |
| 261 | "homeOpacity": home, |
| 262 | "taskOpacity": task, |
| 263 | "overlayStrength": overlay, |
| 264 | }, |
| 265 | } |
| 266 | tdir = os.path.join(DEST, tid) |
| 267 | os.makedirs(tdir, exist_ok=True) |
| 268 | with open(os.path.join(tdir, "theme.json"), "w") as f: |
| 269 | json.dump(manifest, f, indent=2, ensure_ascii=False) |
| 270 | f.write("\n") |
| 271 | for name in ("background.webp", "preview.webp"): |
| 272 | src = os.path.join(OUT, tid, name) |
| 273 | shutil.copyfile(src, os.path.join(tdir, name)) |
| 274 | print(f"wrote {tdir}") |
| 275 | |
| 276 | |
| 277 | if __name__ == "__main__": |
| 278 | bad = check() |
| 279 | if bad: |
| 280 | sys.exit(1) |
| 281 | if "--write" in sys.argv: |
| 282 | write_manifests() |
| 283 |