"""Per-visitor look & feel: a validated JSON theme spec (design tokens) the agent can patch. The server never stores themes; it only validates what the model proposes. The browser keeps the theme in localStorage, so one visitor's restyle never affects anyone else. No raw HTML/JS is ever accepted; the optional customCss escape hatch is sanitised and scoped (see sanitize_css).""" import re COLOR_RE = re.compile(r"^(#[0-9a-fA-F]{3,4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8}|" r"rgba?\(\s*[\d.]+%?\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?\s*(,\s*[\d.]+%?\s*)?\)|" r"hsla?\(\s*[\d.]+(deg)?\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*(,\s*[\d.]+%?\s*)?\)|" r"[a-zA-Z]{3,24})$") FONTS = { # name -> (css stack, google family or None) "system": ("system-ui,-apple-system,Segoe UI,Roboto,sans-serif", None), "serif": ("Georgia,'Times New Roman',serif", None), "mono": ("ui-monospace,Menlo,Consolas,monospace", None), "Inter": ("'Inter',system-ui,sans-serif", "Inter:wght@400;600;700"), "Poppins": ("'Poppins',system-ui,sans-serif", "Poppins:wght@400;600;700"), "Montserrat": ("'Montserrat',system-ui,sans-serif", "Montserrat:wght@400;600;700"), "Nunito": ("'Nunito',system-ui,sans-serif", "Nunito:wght@400;700"), "Space Grotesk": ("'Space Grotesk',system-ui,sans-serif", "Space+Grotesk:wght@400;700"), "Lora": ("'Lora',Georgia,serif", "Lora:wght@400;700"), "Merriweather": ("'Merriweather',Georgia,serif", "Merriweather:wght@400;700"), "Playfair Display": ("'Playfair Display',Georgia,serif", "Playfair+Display:wght@400;700"), "Roboto Slab": ("'Roboto Slab',Georgia,serif", "Roboto+Slab:wght@400;700"), "Fira Code": ("'Fira Code',ui-monospace,monospace", "Fira+Code:wght@400;600"), "Space Mono": ("'Space Mono',ui-monospace,monospace", "Space+Mono:wght@400;700"), "VT323": ("'VT323',ui-monospace,monospace", "VT323"), "Press Start 2P": ("'Press Start 2P',ui-monospace,monospace", "Press+Start+2P"), "Bebas Neue": ("'Bebas Neue',Impact,sans-serif", "Bebas+Neue"), "Pacifico": ("'Pacifico',cursive", "Pacifico"), "Comic Neue": ("'Comic Neue','Comic Sans MS',cursive", "Comic+Neue:wght@400;700"), } _FONT_ALIASES = {"sans": "system", "sans-serif": "system", "default": "system", "georgia": "serif", "times": "serif", "monospace": "mono", "courier": "mono", "pixel": "Press Start 2P", "retro": "VT323", "script": "Pacifico", "comic": "Comic Neue", "comic sans": "Comic Neue", "playfair": "Playfair Display"} PATTERNS = ["dots", "grid", "stripes", "diagonal", "checks", "waves"] IMAGE_HOSTS = {"images.unsplash.com", "picsum.photos", "upload.wikimedia.org"} SECTIONS = ["hero", "chips", "bullets", "results", "footer"] PALETTE_KEYS = ["bg", "surface", "card", "text", "muted", "accent", "border"] PRESETS = { "retro": {"mode": "light", "background": {"kind": "pattern", "pattern": "checks", "colors": ["#f4e3c1", "#ead2a6"]}, "palette": {"surface": "#fff6e3", "card": "#fffaf0", "text": "#3a2a1a", "muted": "#7a5c3e", "accent": "#d9480f", "border": "#3a2a1a"}, "font": "VT323", "headingFont": "Press Start 2P", "fontScale": 1.15, "radius": 0, "cardStyle": "outlined", "density": "normal"}, "minimal": {"mode": "light", "background": {"kind": "solid", "colors": ["#ffffff"]}, "palette": {"surface": "#ffffff", "card": "#ffffff", "text": "#111111", "muted": "#666666", "accent": "#111111", "border": "#e6e6e6"}, "font": "Inter", "headingFont": "Inter", "radius": 4, "cardStyle": "flat", "density": "comfy", "animations": False}, "neon": {"mode": "dark", "background": {"kind": "gradient", "colors": ["#05010f", "#1a0033", "#00111f"], "angle": 160}, "palette": {"surface": "#0d0620", "card": "#130a2b", "text": "#f5f0ff", "muted": "#b9a6ff", "accent": "#39ff14", "border": "#ff00e6"}, "font": "Space Grotesk", "headingFont": "Bebas Neue", "radius": 12, "cardStyle": "glass", "customCss": ".card{box-shadow:0 0 12px #ff00e6aa} h1{text-shadow:0 0 18px #39ff14}"}, "pastel": {"mode": "light", "background": {"kind": "gradient", "colors": ["#fde2e4", "#e2ece9", "#dfe7fd"], "angle": 135}, "palette": {"surface": "#fffafc", "card": "#ffffff", "text": "#3d3a4b", "muted": "#7d7a8c", "accent": "#b388eb", "border": "#ecdff5"}, "font": "Nunito", "headingFont": "Nunito", "radius": 22, "cardStyle": "elevated", "density": "comfy"}, "high contrast": {"mode": "dark", "background": {"kind": "solid", "colors": ["#000000"]}, "palette": {"surface": "#000000", "card": "#000000", "text": "#ffffff", "muted": "#ffffff", "accent": "#ffff00", "border": "#ffffff"}, "font": "system", "fontScale": 1.2, "radius": 2, "cardStyle": "outlined", "animations": False}, "sunset": {"mode": "dark", "background": {"kind": "gradient", "colors": ["#ff7e5f", "#feb47b", "#6a3093"], "angle": 180}, "palette": {"surface": "#2b1531", "card": "#3a1d40", "text": "#fff4ec", "muted": "#ffd2b8", "accent": "#ff9966", "border": "#6a3093"}, "radius": 16, "cardStyle": "glass"}, "ocean": {"mode": "dark", "background": {"kind": "gradient", "colors": ["#0f2027", "#203a43", "#2c5364"], "angle": 180}, "palette": {"surface": "#10262f", "card": "#163542", "text": "#e6f6ff", "muted": "#9cc7d9", "accent": "#4fd1c5", "border": "#2c5364"}, "font": "Montserrat", "radius": 14}, } PRESET_ALIASES = {"high-contrast": "high contrast", "contrast": "high contrast", "accessible": "high contrast", "80s": "retro", "vintage": "retro", "clean": "minimal", "cyberpunk": "neon", "soft": "pastel"} def _color(v): return v.strip() if isinstance(v, str) and COLOR_RE.match(v.strip()) else None def _num(v, lo, hi, cast=float): try: x = cast(v) except (TypeError, ValueError): return None return max(lo, min(hi, x)) def _text(v, n): if not isinstance(v, str): return None v = re.sub(r"[<>\x00-\x1f]", "", v).strip() return v[:n] if v else None def _font(v): if not isinstance(v, str): return None k = v.strip() for name in FONTS: if name.lower() == k.lower(): return name return _FONT_ALIASES.get(k.lower()) CSS_PROPS = { # allow-list for the customCss escape hatch (no positioning, transforms, content or variables) "color", "background", "background-color", "background-image", "background-size", "background-position", "background-repeat", "background-attachment", "border", "border-color", "border-width", "border-style", "border-top", "border-bottom", "border-left", "border-right", "border-radius", "box-shadow", "outline", "outline-color", "font", "font-family", "font-size", "font-weight", "font-style", "letter-spacing", "line-height", "text-align", "text-transform", "text-decoration", "text-shadow", "padding", "padding-top", "padding-bottom", "padding-left", "padding-right", "margin", "margin-top", "margin-bottom", "margin-left", "margin-right", "gap", "opacity", "filter", "backdrop-filter", "width", "max-width", "min-width", "min-height", "display", "flex-direction", "flex-wrap", "justify-content", "align-items", "grid-template-columns", "cursor", "transition", "accent-color", "caret-color", "word-spacing", "font-variant", } def _url_ok(m): host = re.match(r"https://([^/'\")\s]+)", m.group(1).strip("'\" ")) return m.group(0) if host and host.group(1).lower() in IMAGE_HOSTS else "none" def sanitize_css(css): """Return sanitised CSS rules (selectors unscoped; the browser prefixes them with #page).""" if not isinstance(css, str): return "" css = css[:3000] css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) if re.search(r"<|>\s*<|\\|@|expression\s*\(|javascript:|behavior\s*:|-moz-binding|vbscript:", css, re.I): css = re.sub(r"<[^>]*>?|\\|@[^;{}]*(;|\{[^}]*\})?|expression\s*\([^)]*\)|javascript:|vbscript:|behavior\s*:[^;}]*|-moz-binding[^;}]*", "", css, flags=re.I) out = [] for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css): sel = sel.strip() if not sel or len(sel) > 200 or re.search(r"[<>@\\]|^\s*(html|body|:root)\b", sel, re.I): continue decls = [] for d in body.split(";"): if ":" not in d: continue prop, val = d.split(":", 1) prop, val = prop.strip().lower(), val.strip() if prop not in CSS_PROPS or not val or len(val) > 300: continue val = re.sub(r"!\s*important", "", val, flags=re.I).strip() val = re.sub(r"url\(([^)]*)\)", _url_ok, val, flags=re.I) if re.search(r"var\(|attr\(|expression|javascript", val, re.I) or val.count("(") != val.count(")"): continue if prop.startswith("margin") and "-" in val: continue # no negative margins (could overlap protected panels) decls.append(f"{prop}:{val}") if decls: out.append(f"{sel}{{{';'.join(decls)}}}") return "\n".join(out)[:3000] def sanitize_patch(p, depth=0): """Validate a theme patch from the model. Unknown keys/values are dropped silently.""" if not isinstance(p, dict): return {} out = {} if p.get("mode") in ("light", "dark"): out["mode"] = p["mode"] bg = p.get("background") if isinstance(bg, str): bg = {"kind": "solid", "colors": [bg]} if isinstance(bg, dict): b = {} kind = bg.get("kind") or bg.get("type") if kind in ("solid", "gradient", "pattern", "image"): b["kind"] = kind cols = bg.get("colors") or ([bg["color"]] if bg.get("color") else []) cols = [c for c in (_color(c) for c in (cols if isinstance(cols, list) else [])) if c][:5] if cols: b["colors"] = cols a = _num(bg.get("angle"), 0, 360, int) if a is not None: b["angle"] = a if bg.get("pattern") in PATTERNS: b["pattern"] = bg["pattern"] url = bg.get("url") or bg.get("image") if isinstance(url, str): m = re.match(r"^https://([^/\s'\"()]+)/[^\s'\"()<>]*$", url.strip()) if m and m.group(1).lower() in IMAGE_HOSTS: b["url"] = url.strip() if b.get("kind") == "image" and "url" not in b: b.pop("kind") if b: out["background"] = b pal = p.get("palette") if isinstance(pal, dict): aliases = {"background": "bg", "panel": "surface", "ink": "text", "foreground": "text", "mute": "muted", "line": "border"} clean = {} for k, v in pal.items(): k = aliases.get(k, k) if k in PALETTE_KEYS and _color(v): clean[k] = _color(v) if clean: out["palette"] = clean for k in ("font", "headingFont"): f = _font(p.get(k)) if f: out[k] = f for k, lo, hi, cast in (("fontScale", 0.75, 1.75, float), ("radius", 0, 32, int), ("columns", 0, 6, int)): v = _num(p.get(k), lo, hi, cast) if v is not None and p.get(k) is not None: out[k] = round(v, 2) if cast is float else v for k, allowed in (("density", ("compact", "normal", "comfy")), ("cardStyle", ("flat", "outlined", "elevated", "glass")), ("layout", ("grid", "list"))): if p.get(k) in allowed: out[k] = p[k] if isinstance(p.get("animations"), bool): out["animations"] = p["animations"] copy = p.get("copy") if isinstance(copy, dict): c = {} for k, n in (("logo", 40), ("headline", 120), ("subhead", 240), ("footer", 160)): t = _text(copy.get(k), n) if t: c[k] = t if isinstance(copy.get("chips"), list): chips = [] for ch in copy["chips"][:6]: if isinstance(ch, str): ch = {"label": ch, "say": ch} if isinstance(ch, dict) and _text(ch.get("label"), 40): if ch.get("event") in ("sell",): chips.append({"label": _text(ch["label"], 40), "event": ch["event"]}) else: chips.append({"label": _text(ch["label"], 40), "say": _text(ch.get("say") or ch["label"], 120)}) c["chips"] = chips if isinstance(copy.get("bullets"), list): c["bullets"] = [t for t in (_text(b, 140) for b in copy["bullets"][:6]) if t] if c: out["copy"] = c sec = p.get("sections") if isinstance(sec, dict): s = {} if isinstance(sec.get("order"), list): order = [x for x in sec["order"] if x in SECTIONS] s["order"] = list(dict.fromkeys(order + [x for x in SECTIONS if x not in order])) if isinstance(sec.get("hidden"), list): s["hidden"] = [x for x in dict.fromkeys(sec["hidden"]) if x in SECTIONS] if s: out["sections"] = s chat = p.get("chat") if isinstance(chat, dict): c = {} if chat.get("position") in ("left", "right"): c["position"] = chat["position"] for k, lo, hi in (("width", 280, 600), ("height", 320, 820)): v = _num(chat.get(k), lo, hi, int) if v is not None: c[k] = v for k in ("bg", "accent", "text"): if _color(chat.get(k)): c[k] = _color(chat[k]) if c: out["chat"] = c if p.get("customCss"): css = sanitize_css(p["customCss"]) if css: out["customCss"] = css return out def build_theme_action(payload): """Turn a model's set_theme payload into a client action: {reset?, base?, patch}.""" if not isinstance(payload, dict): return None name = str(payload.get("preset") or "").strip().lower() if payload.get("reset") and not name and not [k for k in payload if k not in ("reset", "patch")]: return {"reset": True, "patch": sanitize_patch(payload.get("patch") or {})} # {"preset": "sunset", "reset": true} means "start over from that preset" (a preset base already replaces the look) name = PRESET_ALIASES.get(name, name) patch = payload.get("patch") if isinstance(payload.get("patch"), dict) else {k: v for k, v in payload.items() if k not in ("preset", "reset")} out = {"patch": sanitize_patch(patch)} if payload.get("reset"): out["reset"] = True if name in PRESETS: out["preset"] = name out["base"] = sanitize_patch(PRESETS[name]) if not out["patch"] and "base" not in out: return None return out def compact_current(theme): """Short current-look summary for the prompt (keeps free-tier calls lean).""" import json t = sanitize_patch(theme) if isinstance(theme, dict) else {} t.pop("customCss", None) s = json.dumps(t, separators=(",", ":")) return s if len(s) <= 900 else s[:900] # ---------- rule-based look commands (used when the hosted model is unavailable) ---------- NAMED = {"red": "#e5484d", "green": "#2fb36d", "blue": "#3d7bfd", "purple": "#8e4ec6", "violet": "#8e4ec6", "pink": "#e93d82", "orange": "#f76b15", "yellow": "#ffd23f", "teal": "#12a594", "cyan": "#00b8d9", "black": "#0b0b0f", "white": "#ffffff", "grey": "#8b8d98", "gray": "#8b8d98", "navy": "#1b2a4a", "gold": "#d4a72c", "brown": "#8d5a3b", "beige": "#efe3cf", "cream": "#fff8e7", "mint": "#9de0c4", "lavender": "#c9b8f0", "coral": "#ff7f6e", "lime": "#9ae600", "magenta": "#d62fd6", "maroon": "#6d1a2b", "olive": "#7a8c3c", "sky": "#8ecdf7", "peach": "#ffcfa8", "turquoise": "#2ad4c4", "indigo": "#4b4bd6"} GRADIENTS = {"sunset": ["#ff7e5f", "#feb47b", "#6a3093"], "ocean": ["#0f2027", "#203a43", "#2c5364"], "forest": ["#134e5e", "#71b280"], "candy": ["#f9a8d4", "#a5b4fc"], "fire": ["#f12711", "#f5af19"], "night": ["#0f0c29", "#302b63", "#24243e"], "peach": ["#ffecd2", "#fcb69f"], "aurora": ["#00c9a7", "#845ec2", "#0081cf"], "rainbow": ["#ff5f6d", "#ffc371", "#47e891", "#3d7bfd", "#8e4ec6"]} SECTION_WORDS = {"hero": "hero", "headline": "hero", "title": "hero", "chips": "chips", "quick actions": "chips", "quick buttons": "chips", "buttons": "chips", "bullets": "bullets", "bullet points": "bullets", "facts": "bullets", "results": "results", "listings": "results", "footer": "footer"} _CW = "|".join(sorted(NAMED, key=len, reverse=True)) def parse_look_command(text, current=None): """Map a plain-English look request to a set_theme payload. Returns (reply, payload) or None.""" low = (text or "").lower().strip() cur = current if isinstance(current, dict) else {} if re.search(r"\breset\b.*\b(look|theme|style|design|page|colou?rs?)\b|\b(default|original) (look|theme)\b|^reset$", low): return "Done: the page is back to its default look.", {"reset": True} # UI copy edits first ("change the headline to Retro vibes" is text, not the retro preset) from .copyedit import parse_copy_edit ce = parse_copy_edit(text, cur) if ce: return ce[0], (sanitize_patch(ce[1]) if ce[1] else None) for name in list(PRESETS) + list(PRESET_ALIASES): if re.search(r"\b" + re.escape(name) + r"\b", low) and re.search(r"look|theme|style|make it|switch|preset|vibe|feel", low) \ and not re.search(r"background|gradient", low): real = PRESET_ALIASES.get(name, name) return f"Applied the {real} look. Say “reset the look” to go back.", {"preset": real} patch, said = {}, [] if re.search(r"\blight (mode|theme)\b|\bmake it light\b", low): patch["mode"] = "light"; said.append("light mode") elif re.search(r"\bdark (mode|theme)\b|\bmake it dark\b", low): patch["mode"] = "dark"; said.append("dark mode") if "background" in low or "gradient" in low or re.search(r"\b(dots|polka|stripes|checks|checkered|grid pattern|waves)\b", low): g = next((k for k in GRADIENTS if k in low), None) cols = re.findall(r"\b(" + _CW + r")\b", low) pat = next((p for k, p in (("polka", "dots"), ("dot", "dots"), ("stripe", "stripes"), ("check", "checks"), ("diagonal", "diagonal"), ("wave", "waves"), ("grid", "grid")) if k in low), None) if g: patch["background"] = {"kind": "gradient", "colors": GRADIENTS[g], "angle": 180}; said.append(f"{g} gradient background") elif pat and "background" in low or pat and "pattern" in low: c = [NAMED[x] for x in cols[:2]] or ["#1d212a", "#2a2f3a"] patch["background"] = {"kind": "pattern", "pattern": pat, "colors": c if len(c) > 1 else [c[0], "#ffffff22"]}; said.append(f"{pat} pattern background") elif "gradient" in low and cols: c = [NAMED[x] for x in cols[:4]] patch["background"] = {"kind": "gradient", "colors": c if len(c) > 1 else [c[0], "#000000"], "angle": 135}; said.append("gradient background") elif cols: patch["background"] = {"kind": "solid", "colors": [NAMED[cols[0]]]}; said.append(f"{cols[0]} background") m = re.search(r"\b(" + _CW + r")\s+accent|accent(?: colou?r)?\s+(?:to\s+)?(" + _CW + r")\b", low) if m: c = m.group(1) or m.group(2); patch.setdefault("palette", {})["accent"] = NAMED[c]; said.append(f"{c} accent") m = re.search(r"\b(" + _CW + r")\s+text\b|\btext\s+(?:colou?r\s+)?(?:to\s+)?(" + _CW + r")\b", low) if m: c = m.group(1) or m.group(2); patch.setdefault("palette", {})["text"] = NAMED[c]; said.append(f"{c} text") for name in sorted(FONTS, key=len, reverse=True): if name != "system" and re.search(r"\b" + re.escape(name.lower()) + r"\b", low) \ and (re.search(r"\b(font|typeface|type|lettering|letters|text)\b", low) or " " in name): patch["font"] = name; said.append(f"{name} font"); break else: for alias, name in (("monospace", "mono"), ("serif", "serif"), ("handwrit", "Pacifico"), ("script", "Pacifico"), ("pixel", "Press Start 2P"), ("comic", "Comic Neue"), ("typewriter", "Space Mono"), ("sans", "Inter")): if alias in low and "font" in low and not ("sans" in low and alias == "serif"): patch["font"] = name; said.append(f"{name} font"); break scale = float(cur.get("fontScale") or 1) if re.search(r"(bigger|larger|increase|bump up).{0,12}(text|font)|(text|font).{0,12}(bigger|larger)", low): patch["fontScale"] = round(min(1.75, scale + 0.15), 2); said.append(f"text size {patch['fontScale']}×") elif re.search(r"(smaller|decrease|reduce).{0,12}(text|font)|(text|font).{0,12}smaller", low): patch["fontScale"] = round(max(0.75, scale - 0.15), 2); said.append(f"text size {patch['fontScale']}×") if re.search(r"sharp|square corners|no rounding", low): patch["radius"] = 0; said.append("sharp corners") elif re.search(r"pill|very rounded|super rounded", low): patch["radius"] = 30; said.append("pill corners") elif re.search(r"rounded|round corners|softer corners", low): patch["radius"] = 22; said.append("rounded corners") if re.search(r"compact|denser|tighter|less spac", low): patch["density"] = "compact"; said.append("compact spacing") elif re.search(r"spacious|airy|more space|roomier|comfy", low): patch["density"] = "comfy"; said.append("roomier spacing") for k, v in (("flat", "flat"), ("outlined", "outlined"), ("outline", "outlined"), ("shadow", "elevated"), ("elevated", "elevated"), ("glass", "glass")): if re.search(r"\b" + k + r"\b", low) and ("card" in low or "style" in low): patch["cardStyle"] = v; said.append(f"{v} cards"); break if re.search(r"as a list|list (view|layout)|not cards|as rows", low): patch["layout"] = "list"; said.append("list layout") elif re.search(r"grid (view|layout)|as cards|as a grid", low): patch["layout"] = "grid"; said.append("grid layout") m = re.search(r"\b([1-6]|one|two|three|four|five|six)\s+columns?\b", low) if m: n = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6}.get(m.group(1)) or int(m.group(1)) patch["columns"] = n; patch.setdefault("layout", "grid"); said.append(f"{n} columns") if "chat" in low: ch = {} if re.search(r"\bleft\b", low): ch["position"] = "left" elif re.search(r"\bright\b", low): ch["position"] = "right" if re.search(r"(bigger|larger|wider|taller)", low): ch.update(width=480, height=680) elif re.search(r"(smaller|narrower)", low): ch.update(width=320, height=420) if ch: patch["chat"] = ch; said.append("chat " + ", ".join(f"{k} {v}" for k, v in ch.items())) if re.search(r"(no|stop|turn off|disable|without) animations?|animations? off", low): patch["animations"] = False; said.append("animations off") elif re.search(r"(turn on|enable) animations?|animations? on", low): patch["animations"] = True; said.append("animations on") hide = re.search(r"\b(hide|remove|get rid of)\b(.+)", low) show = re.search(r"\b(show|unhide|bring back)\b(.+)", low) hidden = list((cur.get("sections") or {}).get("hidden") or []) for verb, mm in (("hide", hide), ("show", show)): if not mm: continue for w, sec in SECTION_WORDS.items(): if re.search(r"\b" + w + r"\b", mm.group(2)): if verb == "hide" and sec not in hidden: hidden.append(sec); said.append(f"{sec} hidden"); patch["sections"] = {"hidden": hidden} elif verb == "show" and sec in hidden: hidden.remove(sec); said.append(f"{sec} shown"); patch["sections"] = {"hidden": hidden} break m = re.search(r"move (?:the )?(\w+(?: \w+)?) to the (top|bottom)", low) if m and "chat" not in m.group(1): sec = next((s for w, s in SECTION_WORDS.items() if w in m.group(1)), None) if sec: order = [x for x in ((cur.get("sections") or {}).get("order") or SECTIONS) if x != sec] order = [sec] + order if m.group(2) == "top" else order + [sec] patch.setdefault("sections", {})["order"] = order; said.append(f"{sec} moved to the {m.group(2)}") patch = sanitize_patch(patch) if not patch: return None return "Done: " + ", ".join(said) + ".", patch