agent-market/market/theme.py

418 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
if payload.get("reset"):
return {"reset": True, "patch": sanitize_patch(payload.get("patch") or {})}
name = str(payload.get("preset") or "").strip().lower()
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 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