From 19d59f5f4bc36ac6c071093ac9142e365a1290ef Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 12:47:51 +0000 Subject: [PATCH] Implement CSSX Phase 2: native SX style primitives Replace Tailwind class strings with native SX expressions: (css :flex :gap-4 :hover:bg-sky-200) instead of :class "flex gap-4 ..." - Add style_dict.py: 516 atoms, variants, breakpoints, keyframes, patterns - Add style_resolver.py: memoized resolver with variant splitting - Add StyleValue type to types.py (frozen dataclass with class_name, declarations, etc.) - Add css and merge-styles primitives to primitives.py - Add defstyle and defkeyframes special forms to evaluator.py and async_eval.py - Integrate StyleValue into html.py and async_eval.py render paths - Add register_generated_rule() to css_registry.py, fix media query selector - Add style dict JSON delivery with localStorage caching to helpers.py - Add client-side css primitive, resolver, and style injection to sx.js Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx.js | 350 ++++++++++++++++- shared/sx/async_eval.py | 65 +++- shared/sx/css_registry.py | 42 +++ shared/sx/evaluator.py | 74 ++++ shared/sx/helpers.py | 63 ++++ shared/sx/html.py | 15 +- shared/sx/parser.py | 5 + shared/sx/primitives.py | 38 ++ shared/sx/style_dict.py | 735 ++++++++++++++++++++++++++++++++++++ shared/sx/style_resolver.py | 254 +++++++++++++ shared/sx/types.py | 26 +- 11 files changed, 1660 insertions(+), 7 deletions(-) create mode 100644 shared/sx/style_dict.py create mode 100644 shared/sx/style_resolver.py diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index d919f2b..85e4b7b 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -63,12 +63,23 @@ function RawHTML(html) { this.html = html; } RawHTML.prototype._raw = true; + /** CSSX StyleValue — generated CSS class with rules. */ + function StyleValue(className, declarations, mediaRules, pseudoRules, keyframes) { + this.className = className; + this.declarations = declarations || ""; + this.mediaRules = mediaRules || []; + this.pseudoRules = pseudoRules || []; + this.keyframes = keyframes || []; + } + StyleValue.prototype._styleValue = true; + function isSym(x) { return x && x._sym === true; } function isKw(x) { return x && x._kw === true; } function isLambda(x) { return x && x._lambda === true; } function isComponent(x) { return x && x._component === true; } function isMacro(x) { return x && x._macro === true; } function isRaw(x) { return x && x._raw === true; } + function isStyleValue(x) { return x && x._styleValue === true; } // --- Parser --- @@ -341,6 +352,227 @@ return r; }; + // --- CSSX Style Dictionary + Resolver --- + + var _styleAtoms = {}; // atom → CSS declarations + var _pseudoVariants = {}; // variant → CSS pseudo-selector + var _responsiveBreakpoints = {}; // variant → media query + var _styleKeyframes = {}; // name → @keyframes rule + var _arbitraryPatterns = []; // [{re: RegExp, tmpl: string}, ...] + var _childSelectorPrefixes = []; // ["space-x-", "space-y-", ...] + var _styleCache = {}; // atoms-key → StyleValue + var _injectedStyles = {}; // className → true (already in + @@ -681,6 +682,14 @@ def sx_page(ctx: dict, page_sx: str, *, except Exception: pass + # Style dictionary for client-side css primitive + styles_hash = _get_style_dict_hash() + client_styles_hash = _get_sx_styles_cookie() + if client_styles_hash and client_styles_hash == styles_hash: + styles_json = "" # Client has cached version + else: + styles_json = _build_style_dict_json() + return _SX_PAGE_TEMPLATE.format( title=_html_escape(title), asset_url=asset_url, @@ -688,6 +697,8 @@ def sx_page(ctx: dict, page_sx: str, *, csrf=_html_escape(csrf), component_hash=component_hash, component_defs=component_defs, + styles_hash=styles_hash, + styles_json=styles_json, page_sx=page_sx, sx_css=sx_css, sx_css_classes=sx_css_classes, @@ -697,6 +708,58 @@ def sx_page(ctx: dict, page_sx: str, *, _SCRIPT_HASH_CACHE: dict[str, str] = {} +_STYLE_DICT_JSON: str = "" +_STYLE_DICT_HASH: str = "" + + +def _build_style_dict_json() -> str: + """Build compact JSON style dictionary for client-side css primitive.""" + global _STYLE_DICT_JSON, _STYLE_DICT_HASH + if _STYLE_DICT_JSON: + return _STYLE_DICT_JSON + + import json + from .style_dict import ( + STYLE_ATOMS, PSEUDO_VARIANTS, RESPONSIVE_BREAKPOINTS, + KEYFRAMES, ARBITRARY_PATTERNS, CHILD_SELECTOR_ATOMS, + ) + + # Derive child selector prefixes from CHILD_SELECTOR_ATOMS + prefixes = set() + for atom in CHILD_SELECTOR_ATOMS: + # "space-y-4" → "space-y-", "divide-y" → "divide-" + for sep in ("space-x-", "space-y-", "divide-x", "divide-y"): + if atom.startswith(sep): + prefixes.add(sep) + break + + data = { + "a": STYLE_ATOMS, + "v": PSEUDO_VARIANTS, + "b": RESPONSIVE_BREAKPOINTS, + "k": KEYFRAMES, + "p": ARBITRARY_PATTERNS, + "c": sorted(prefixes), + } + _STYLE_DICT_JSON = json.dumps(data, separators=(",", ":")) + _STYLE_DICT_HASH = hashlib.md5(_STYLE_DICT_JSON.encode()).hexdigest()[:8] + return _STYLE_DICT_JSON + + +def _get_style_dict_hash() -> str: + """Get the hash of the style dictionary JSON.""" + if not _STYLE_DICT_HASH: + _build_style_dict_json() + return _STYLE_DICT_HASH + + +def _get_sx_styles_cookie() -> str: + """Read the sx-styles-hash cookie from the current request.""" + try: + from quart import request + return request.cookies.get("sx-styles-hash", "") + except Exception: + return "" def _script_hash(filename: str) -> str: diff --git a/shared/sx/html.py b/shared/sx/html.py index 3857dbb..510499a 100644 --- a/shared/sx/html.py +++ b/shared/sx/html.py @@ -27,7 +27,7 @@ from __future__ import annotations import contextvars from typing import Any -from .types import Component, Keyword, Lambda, Macro, NIL, Symbol +from .types import Component, Keyword, Lambda, Macro, NIL, StyleValue, Symbol from .evaluator import _eval as _raw_eval, _call_component as _raw_call_component, _expand_macro, _trampoline def _eval(expr, env): @@ -479,6 +479,19 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str: children.append(arg) i += 1 + # Handle :style StyleValue — convert to class and register CSS rule + style_val = attrs.get("style") + if isinstance(style_val, StyleValue): + from .css_registry import register_generated_rule + register_generated_rule(style_val) + # Merge into :class + existing_class = attrs.get("class") + if existing_class and existing_class is not NIL and existing_class is not False: + attrs["class"] = f"{existing_class} {style_val.class_name}" + else: + attrs["class"] = style_val.class_name + del attrs["style"] + # Collect CSS classes if collector is active class_val = attrs.get("class") if class_val is not None and class_val is not NIL and class_val is not False: diff --git a/shared/sx/parser.py b/shared/sx/parser.py index d5ed399..15c9a7c 100644 --- a/shared/sx/parser.py +++ b/shared/sx/parser.py @@ -336,6 +336,11 @@ def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str: items.append(serialize(v, indent, pretty)) return "{" + " ".join(items) + "}" + # StyleValue — serialize as class name string + from .types import StyleValue + if isinstance(expr, StyleValue): + return f'"{expr.class_name}"' + # _RawHTML — pre-rendered HTML; wrap as (raw! "...") for SX wire format from .html import _RawHTML if isinstance(expr, _RawHTML): diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index d903587..9dd202b 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -573,3 +573,41 @@ def prim_route_prefix() -> str: """``(route-prefix)`` → service URL prefix for dev/prod routing.""" from shared.utils import route_prefix return route_prefix() + + +# --------------------------------------------------------------------------- +# Style primitives +# --------------------------------------------------------------------------- + +@register_primitive("css") +def prim_css(*args: Any) -> Any: + """``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue. + + Accepts keyword atoms (strings without colon prefix) and runtime + strings. Returns a StyleValue with a content-addressed class name + and all resolved CSS declarations. + """ + from .style_resolver import resolve_style + atoms = tuple( + (a.name if isinstance(a, Keyword) else str(a)) + for a in args if a is not None and a is not NIL and a is not False + ) + if not atoms: + return NIL + return resolve_style(atoms) + + +@register_primitive("merge-styles") +def prim_merge_styles(*styles: Any) -> Any: + """``(merge-styles style1 style2)`` → merged StyleValue. + + Merges multiple StyleValues; later declarations win. + """ + from .types import StyleValue + from .style_resolver import merge_styles + valid = [s for s in styles if isinstance(s, StyleValue)] + if not valid: + return NIL + if len(valid) == 1: + return valid[0] + return merge_styles(valid) diff --git a/shared/sx/style_dict.py b/shared/sx/style_dict.py new file mode 100644 index 0000000..199676f --- /dev/null +++ b/shared/sx/style_dict.py @@ -0,0 +1,735 @@ +""" +Style dictionary — maps keyword atoms to CSS declarations. + +Pure data. Each key is a Tailwind-compatible class name (used as an sx keyword +atom in ``(css :flex :gap-4 :p-2)``), and each value is the CSS declaration(s) +that class produces. Declarations are self-contained — no ``--tw-*`` custom +properties needed. + +Generated from the codebase's tw.css via ``css_registry.py`` then simplified +to remove Tailwind v3 variable indirection. + +Used by: + - ``style_resolver.py`` (server) — resolves ``(css ...)`` to StyleValue + - ``sx.js`` (client) — same resolution, cached in localStorage +""" +from __future__ import annotations + + +# ═══════════════════════════════════════════════════════════════════════════ +# Base atoms — keyword → CSS declarations +# ═══════════════════════════════════════════════════════════════════════════ +# +# ~466 atoms covering all utilities used across the codebase. +# Variants (hover:*, sm:*, focus:*, etc.) are NOT stored here — the +# resolver splits "hover:bg-sky-200" into variant="hover" + atom="bg-sky-200" +# and wraps the declaration in the appropriate pseudo/media rule. + +STYLE_ATOMS: dict[str, str] = { + # ── Display ────────────────────────────────────────────────────────── + "block": "display:block", + "inline-block": "display:inline-block", + "inline": "display:inline", + "flex": "display:flex", + "inline-flex": "display:inline-flex", + "table": "display:table", + "grid": "display:grid", + "contents": "display:contents", + "hidden": "display:none", + + # ── Position ───────────────────────────────────────────────────────── + "static": "position:static", + "fixed": "position:fixed", + "absolute": "position:absolute", + "relative": "position:relative", + "inset-0": "inset:0", + "top-0": "top:0", + "top-1/2": "top:50%", + "top-2": "top:.5rem", + "top-20": "top:5rem", + "top-[8px]": "top:8px", + "top-full": "top:100%", + "right-2": "right:.5rem", + "right-[8px]": "right:8px", + "bottom-full": "bottom:100%", + "left-1/2": "left:50%", + "left-2": "left:.5rem", + "-right-2": "right:-.5rem", + "-right-3": "right:-.75rem", + "-top-1.5": "top:-.375rem", + "-top-2": "top:-.5rem", + + # ── Z-Index ────────────────────────────────────────────────────────── + "z-10": "z-index:10", + "z-40": "z-index:40", + "z-50": "z-index:50", + + # ── Grid ───────────────────────────────────────────────────────────── + "grid-cols-1": "grid-template-columns:repeat(1,minmax(0,1fr))", + "grid-cols-2": "grid-template-columns:repeat(2,minmax(0,1fr))", + "grid-cols-3": "grid-template-columns:repeat(3,minmax(0,1fr))", + "grid-cols-4": "grid-template-columns:repeat(4,minmax(0,1fr))", + "grid-cols-5": "grid-template-columns:repeat(5,minmax(0,1fr))", + "grid-cols-6": "grid-template-columns:repeat(6,minmax(0,1fr))", + "grid-cols-7": "grid-template-columns:repeat(7,minmax(0,1fr))", + "grid-cols-12": "grid-template-columns:repeat(12,minmax(0,1fr))", + "col-span-2": "grid-column:span 2/span 2", + "col-span-3": "grid-column:span 3/span 3", + "col-span-4": "grid-column:span 4/span 4", + "col-span-5": "grid-column:span 5/span 5", + "col-span-12": "grid-column:span 12/span 12", + "col-span-full": "grid-column:1/-1", + + # ── Flexbox ────────────────────────────────────────────────────────── + "flex-row": "flex-direction:row", + "flex-col": "flex-direction:column", + "flex-wrap": "flex-wrap:wrap", + "flex-1": "flex:1 1 0%", + "flex-shrink-0": "flex-shrink:0", + "shrink-0": "flex-shrink:0", + "flex-shrink": "flex-shrink:1", + + # ── Alignment ──────────────────────────────────────────────────────── + "items-start": "align-items:flex-start", + "items-end": "align-items:flex-end", + "items-center": "align-items:center", + "items-baseline": "align-items:baseline", + "justify-start": "justify-content:flex-start", + "justify-end": "justify-content:flex-end", + "justify-center": "justify-content:center", + "justify-between": "justify-content:space-between", + "self-start": "align-self:flex-start", + "self-center": "align-self:center", + "place-items-center": "place-items:center", + + # ── Gap ─────────────────────────────────────────────────────────────── + "gap-px": "gap:1px", + "gap-0.5": "gap:.125rem", + "gap-1": "gap:.25rem", + "gap-1.5": "gap:.375rem", + "gap-2": "gap:.5rem", + "gap-3": "gap:.75rem", + "gap-4": "gap:1rem", + "gap-5": "gap:1.25rem", + "gap-6": "gap:1.5rem", + "gap-8": "gap:2rem", + "gap-[4px]": "gap:4px", + "gap-[8px]": "gap:8px", + "gap-[16px]": "gap:16px", + "gap-x-3": "column-gap:.75rem", + "gap-y-1": "row-gap:.25rem", + + # ── Margin ─────────────────────────────────────────────────────────── + "m-0": "margin:0", + "m-2": "margin:.5rem", + "mx-1": "margin-left:.25rem;margin-right:.25rem", + "mx-2": "margin-left:.5rem;margin-right:.5rem", + "mx-4": "margin-left:1rem;margin-right:1rem", + "mx-auto": "margin-left:auto;margin-right:auto", + "my-3": "margin-top:.75rem;margin-bottom:.75rem", + "-mb-px": "margin-bottom:-1px", + "mb-1": "margin-bottom:.25rem", + "mb-2": "margin-bottom:.5rem", + "mb-3": "margin-bottom:.75rem", + "mb-4": "margin-bottom:1rem", + "mb-6": "margin-bottom:1.5rem", + "mb-8": "margin-bottom:2rem", + "mb-12": "margin-bottom:3rem", + "mb-[8px]": "margin-bottom:8px", + "mb-[24px]": "margin-bottom:24px", + "ml-1": "margin-left:.25rem", + "ml-2": "margin-left:.5rem", + "ml-4": "margin-left:1rem", + "ml-auto": "margin-left:auto", + "mr-1": "margin-right:.25rem", + "mr-2": "margin-right:.5rem", + "mr-3": "margin-right:.75rem", + "mt-0.5": "margin-top:.125rem", + "mt-1": "margin-top:.25rem", + "mt-2": "margin-top:.5rem", + "mt-3": "margin-top:.75rem", + "mt-4": "margin-top:1rem", + "mt-6": "margin-top:1.5rem", + "mt-8": "margin-top:2rem", + "mt-[8px]": "margin-top:8px", + "mt-[16px]": "margin-top:16px", + "mt-[32px]": "margin-top:32px", + + # ── Padding ────────────────────────────────────────────────────────── + "p-0": "padding:0", + "p-1": "padding:.25rem", + "p-1.5": "padding:.375rem", + "p-2": "padding:.5rem", + "p-3": "padding:.75rem", + "p-4": "padding:1rem", + "p-5": "padding:1.25rem", + "p-6": "padding:1.5rem", + "p-8": "padding:2rem", + "px-1": "padding-left:.25rem;padding-right:.25rem", + "px-1.5": "padding-left:.375rem;padding-right:.375rem", + "px-2": "padding-left:.5rem;padding-right:.5rem", + "px-2.5": "padding-left:.625rem;padding-right:.625rem", + "px-3": "padding-left:.75rem;padding-right:.75rem", + "px-4": "padding-left:1rem;padding-right:1rem", + "px-6": "padding-left:1.5rem;padding-right:1.5rem", + "px-[8px]": "padding-left:8px;padding-right:8px", + "px-[12px]": "padding-left:12px;padding-right:12px", + "px-[16px]": "padding-left:16px;padding-right:16px", + "px-[20px]": "padding-left:20px;padding-right:20px", + "py-0.5": "padding-top:.125rem;padding-bottom:.125rem", + "py-1": "padding-top:.25rem;padding-bottom:.25rem", + "py-1.5": "padding-top:.375rem;padding-bottom:.375rem", + "py-2": "padding-top:.5rem;padding-bottom:.5rem", + "py-3": "padding-top:.75rem;padding-bottom:.75rem", + "py-4": "padding-top:1rem;padding-bottom:1rem", + "py-6": "padding-top:1.5rem;padding-bottom:1.5rem", + "py-8": "padding-top:2rem;padding-bottom:2rem", + "py-12": "padding-top:3rem;padding-bottom:3rem", + "py-16": "padding-top:4rem;padding-bottom:4rem", + "py-[6px]": "padding-top:6px;padding-bottom:6px", + "py-[12px]": "padding-top:12px;padding-bottom:12px", + "pb-1": "padding-bottom:.25rem", + "pb-2": "padding-bottom:.5rem", + "pb-3": "padding-bottom:.75rem", + "pb-4": "padding-bottom:1rem", + "pb-6": "padding-bottom:1.5rem", + "pb-8": "padding-bottom:2rem", + "pb-[48px]": "padding-bottom:48px", + "pl-2": "padding-left:.5rem", + "pl-5": "padding-left:1.25rem", + "pl-6": "padding-left:1.5rem", + "pr-1": "padding-right:.25rem", + "pr-2": "padding-right:.5rem", + "pr-4": "padding-right:1rem", + "pt-2": "padding-top:.5rem", + "pt-3": "padding-top:.75rem", + "pt-4": "padding-top:1rem", + "pt-[16px]": "padding-top:16px", + + # ── Width ──────────────────────────────────────────────────────────── + "w-1": "width:.25rem", + "w-2": "width:.5rem", + "w-4": "width:1rem", + "w-5": "width:1.25rem", + "w-6": "width:1.5rem", + "w-8": "width:2rem", + "w-10": "width:2.5rem", + "w-11": "width:2.75rem", + "w-12": "width:3rem", + "w-16": "width:4rem", + "w-20": "width:5rem", + "w-24": "width:6rem", + "w-28": "width:7rem", + "w-48": "width:12rem", + "w-1/2": "width:50%", + "w-1/3": "width:33.333333%", + "w-1/4": "width:25%", + "w-1/6": "width:16.666667%", + "w-2/6": "width:33.333333%", + "w-3/4": "width:75%", + "w-full": "width:100%", + "w-auto": "width:auto", + "w-[1em]": "width:1em", + "w-[32px]": "width:32px", + + # ── Height ─────────────────────────────────────────────────────────── + "h-2": "height:.5rem", + "h-4": "height:1rem", + "h-5": "height:1.25rem", + "h-6": "height:1.5rem", + "h-8": "height:2rem", + "h-10": "height:2.5rem", + "h-12": "height:3rem", + "h-14": "height:3.5rem", + "h-16": "height:4rem", + "h-24": "height:6rem", + "h-28": "height:7rem", + "h-48": "height:12rem", + "h-64": "height:16rem", + "h-full": "height:100%", + "h-[1em]": "height:1em", + "h-[30vh]": "height:30vh", + "h-[32px]": "height:32px", + "h-[60vh]": "height:60vh", + + # ── Min/Max Dimensions ─────────────────────────────────────────────── + "min-w-0": "min-width:0", + "min-w-full": "min-width:100%", + "min-w-[1.25rem]": "min-width:1.25rem", + "min-w-[180px]": "min-width:180px", + "min-h-0": "min-height:0", + "min-h-20": "min-height:5rem", + "min-h-[3rem]": "min-height:3rem", + "min-h-[50vh]": "min-height:50vh", + "max-w-xs": "max-width:20rem", + "max-w-md": "max-width:28rem", + "max-w-lg": "max-width:32rem", + "max-w-2xl": "max-width:42rem", + "max-w-3xl": "max-width:48rem", + "max-w-4xl": "max-width:56rem", + "max-w-full": "max-width:100%", + "max-w-none": "max-width:none", + "max-w-screen-2xl": "max-width:1536px", + "max-w-[360px]": "max-width:360px", + "max-w-[768px]": "max-width:768px", + "max-h-64": "max-height:16rem", + "max-h-96": "max-height:24rem", + "max-h-none": "max-height:none", + "max-h-[448px]": "max-height:448px", + "max-h-[50vh]": "max-height:50vh", + + # ── Typography ─────────────────────────────────────────────────────── + "text-xs": "font-size:.75rem;line-height:1rem", + "text-sm": "font-size:.875rem;line-height:1.25rem", + "text-base": "font-size:1rem;line-height:1.5rem", + "text-lg": "font-size:1.125rem;line-height:1.75rem", + "text-xl": "font-size:1.25rem;line-height:1.75rem", + "text-2xl": "font-size:1.5rem;line-height:2rem", + "text-3xl": "font-size:1.875rem;line-height:2.25rem", + "text-4xl": "font-size:2.25rem;line-height:2.5rem", + "text-5xl": "font-size:3rem;line-height:1", + "text-6xl": "font-size:3.75rem;line-height:1", + "text-8xl": "font-size:6rem;line-height:1", + "text-[8px]": "font-size:8px", + "text-[9px]": "font-size:9px", + "text-[10px]": "font-size:10px", + "text-[11px]": "font-size:11px", + "text-[13px]": "font-size:13px", + "text-[14px]": "font-size:14px", + "text-[16px]": "font-size:16px", + "text-[18px]": "font-size:18px", + "text-[36px]": "font-size:36px", + "text-[40px]": "font-size:40px", + "text-[0.6rem]": "font-size:.6rem", + "text-[0.65rem]": "font-size:.65rem", + "text-[0.7rem]": "font-size:.7rem", + "font-normal": "font-weight:400", + "font-medium": "font-weight:500", + "font-semibold": "font-weight:600", + "font-bold": "font-weight:700", + "font-mono": "font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace", + "italic": "font-style:italic", + "uppercase": "text-transform:uppercase", + "capitalize": "text-transform:capitalize", + "tabular-nums": "font-variant-numeric:tabular-nums", + "leading-none": "line-height:1", + "leading-tight": "line-height:1.25", + "leading-snug": "line-height:1.375", + "leading-relaxed": "line-height:1.625", + "tracking-tight": "letter-spacing:-.025em", + "tracking-wide": "letter-spacing:.025em", + "tracking-widest": "letter-spacing:.1em", + "text-left": "text-align:left", + "text-center": "text-align:center", + "text-right": "text-align:right", + "align-top": "vertical-align:top", + + # ── Text Colors ────────────────────────────────────────────────────── + "text-white": "color:rgb(255 255 255)", + "text-white/80": "color:rgba(255,255,255,.8)", + "text-black": "color:rgb(0 0 0)", + "text-stone-300": "color:rgb(214 211 209)", + "text-stone-400": "color:rgb(168 162 158)", + "text-stone-500": "color:rgb(120 113 108)", + "text-stone-600": "color:rgb(87 83 78)", + "text-stone-700": "color:rgb(68 64 60)", + "text-stone-800": "color:rgb(41 37 36)", + "text-stone-900": "color:rgb(28 25 23)", + "text-slate-400": "color:rgb(148 163 184)", + "text-gray-500": "color:rgb(107 114 128)", + "text-gray-600": "color:rgb(75 85 99)", + "text-red-500": "color:rgb(239 68 68)", + "text-red-600": "color:rgb(220 38 38)", + "text-red-700": "color:rgb(185 28 28)", + "text-red-800": "color:rgb(153 27 27)", + "text-rose-500": "color:rgb(244 63 94)", + "text-rose-600": "color:rgb(225 29 72)", + "text-rose-700": "color:rgb(190 18 60)", + "text-rose-800/80": "color:rgba(159,18,57,.8)", + "text-rose-900": "color:rgb(136 19 55)", + "text-orange-600": "color:rgb(234 88 12)", + "text-amber-500": "color:rgb(245 158 11)", + "text-amber-600": "color:rgb(217 119 6)", + "text-amber-700": "color:rgb(180 83 9)", + "text-amber-800": "color:rgb(146 64 14)", + "text-yellow-700": "color:rgb(161 98 7)", + "text-green-600": "color:rgb(22 163 74)", + "text-green-800": "color:rgb(22 101 52)", + "text-emerald-500": "color:rgb(16 185 129)", + "text-emerald-600": "color:rgb(5 150 105)", + "text-emerald-700": "color:rgb(4 120 87)", + "text-emerald-800": "color:rgb(6 95 70)", + "text-emerald-900": "color:rgb(6 78 59)", + "text-sky-600": "color:rgb(2 132 199)", + "text-sky-700": "color:rgb(3 105 161)", + "text-sky-800": "color:rgb(7 89 133)", + "text-blue-500": "color:rgb(59 130 246)", + "text-blue-600": "color:rgb(37 99 235)", + "text-blue-700": "color:rgb(29 78 216)", + "text-blue-800": "color:rgb(30 64 175)", + "text-purple-600": "color:rgb(147 51 234)", + "text-violet-600": "color:rgb(124 58 237)", + "text-violet-700": "color:rgb(109 40 217)", + "text-violet-800": "color:rgb(91 33 182)", + + # ── Background Colors ──────────────────────────────────────────────── + "bg-transparent": "background-color:transparent", + "bg-white": "background-color:rgb(255 255 255)", + "bg-white/60": "background-color:rgba(255,255,255,.6)", + "bg-white/70": "background-color:rgba(255,255,255,.7)", + "bg-white/80": "background-color:rgba(255,255,255,.8)", + "bg-white/90": "background-color:rgba(255,255,255,.9)", + "bg-black": "background-color:rgb(0 0 0)", + "bg-black/50": "background-color:rgba(0,0,0,.5)", + "bg-stone-50": "background-color:rgb(250 250 249)", + "bg-stone-100": "background-color:rgb(245 245 244)", + "bg-stone-200": "background-color:rgb(231 229 228)", + "bg-stone-300": "background-color:rgb(214 211 209)", + "bg-stone-400": "background-color:rgb(168 162 158)", + "bg-stone-500": "background-color:rgb(120 113 108)", + "bg-stone-600": "background-color:rgb(87 83 78)", + "bg-stone-700": "background-color:rgb(68 64 60)", + "bg-stone-800": "background-color:rgb(41 37 36)", + "bg-stone-900": "background-color:rgb(28 25 23)", + "bg-slate-100": "background-color:rgb(241 245 249)", + "bg-slate-200": "background-color:rgb(226 232 240)", + "bg-gray-100": "background-color:rgb(243 244 246)", + "bg-red-50": "background-color:rgb(254 242 242)", + "bg-red-100": "background-color:rgb(254 226 226)", + "bg-red-200": "background-color:rgb(254 202 202)", + "bg-red-500": "background-color:rgb(239 68 68)", + "bg-red-600": "background-color:rgb(220 38 38)", + "bg-rose-50": "background-color:rgb(255 241 242)", + "bg-rose-50/80": "background-color:rgba(255,241,242,.8)", + "bg-orange-100": "background-color:rgb(255 237 213)", + "bg-amber-50": "background-color:rgb(255 251 235)", + "bg-amber-50/60": "background-color:rgba(255,251,235,.6)", + "bg-amber-100": "background-color:rgb(254 243 199)", + "bg-amber-500": "background-color:rgb(245 158 11)", + "bg-amber-600": "background-color:rgb(217 119 6)", + "bg-yellow-50": "background-color:rgb(254 252 232)", + "bg-yellow-100": "background-color:rgb(254 249 195)", + "bg-yellow-200": "background-color:rgb(254 240 138)", + "bg-yellow-300": "background-color:rgb(253 224 71)", + "bg-green-50": "background-color:rgb(240 253 244)", + "bg-green-100": "background-color:rgb(220 252 231)", + "bg-emerald-50": "background-color:rgb(236 253 245)", + "bg-emerald-50/80": "background-color:rgba(236,253,245,.8)", + "bg-emerald-100": "background-color:rgb(209 250 229)", + "bg-emerald-200": "background-color:rgb(167 243 208)", + "bg-emerald-500": "background-color:rgb(16 185 129)", + "bg-emerald-600": "background-color:rgb(5 150 105)", + "bg-sky-100": "background-color:rgb(224 242 254)", + "bg-sky-200": "background-color:rgb(186 230 253)", + "bg-sky-300": "background-color:rgb(125 211 252)", + "bg-sky-400": "background-color:rgb(56 189 248)", + "bg-sky-500": "background-color:rgb(14 165 233)", + "bg-blue-50": "background-color:rgb(239 246 255)", + "bg-blue-100": "background-color:rgb(219 234 254)", + "bg-blue-600": "background-color:rgb(37 99 235)", + "bg-purple-600": "background-color:rgb(147 51 234)", + "bg-violet-50": "background-color:rgb(245 243 255)", + "bg-violet-100": "background-color:rgb(237 233 254)", + "bg-violet-200": "background-color:rgb(221 214 254)", + "bg-violet-300": "background-color:rgb(196 181 253)", + "bg-violet-400": "background-color:rgb(167 139 250)", + "bg-violet-500": "background-color:rgb(139 92 246)", + "bg-violet-600": "background-color:rgb(124 58 237)", + + # ── Border ─────────────────────────────────────────────────────────── + "border": "border-width:1px", + "border-2": "border-width:2px", + "border-4": "border-width:4px", + "border-t": "border-top-width:1px", + "border-t-0": "border-top-width:0", + "border-b": "border-bottom-width:1px", + "border-b-2": "border-bottom-width:2px", + "border-r": "border-right-width:1px", + "border-l-4": "border-left-width:4px", + "border-dashed": "border-style:dashed", + "border-none": "border-style:none", + "border-transparent": "border-color:transparent", + "border-white": "border-color:rgb(255 255 255)", + "border-white/30": "border-color:rgba(255,255,255,.3)", + "border-stone-100": "border-color:rgb(245 245 244)", + "border-stone-200": "border-color:rgb(231 229 228)", + "border-stone-300": "border-color:rgb(214 211 209)", + "border-stone-700": "border-color:rgb(68 64 60)", + "border-red-200": "border-color:rgb(254 202 202)", + "border-red-300": "border-color:rgb(252 165 165)", + "border-rose-200": "border-color:rgb(254 205 211)", + "border-rose-300": "border-color:rgb(253 164 175)", + "border-amber-200": "border-color:rgb(253 230 138)", + "border-amber-300": "border-color:rgb(252 211 77)", + "border-yellow-200": "border-color:rgb(254 240 138)", + "border-green-300": "border-color:rgb(134 239 172)", + "border-emerald-100": "border-color:rgb(209 250 229)", + "border-emerald-200": "border-color:rgb(167 243 208)", + "border-emerald-300": "border-color:rgb(110 231 183)", + "border-emerald-600": "border-color:rgb(5 150 105)", + "border-blue-200": "border-color:rgb(191 219 254)", + "border-blue-300": "border-color:rgb(147 197 253)", + "border-violet-200": "border-color:rgb(221 214 254)", + "border-violet-300": "border-color:rgb(196 181 253)", + "border-violet-400": "border-color:rgb(167 139 250)", + "border-t-white": "border-top-color:rgb(255 255 255)", + "border-t-stone-600": "border-top-color:rgb(87 83 78)", + "border-l-stone-400": "border-left-color:rgb(168 162 158)", + + # ── Border Radius ──────────────────────────────────────────────────── + "rounded": "border-radius:.25rem", + "rounded-md": "border-radius:.375rem", + "rounded-lg": "border-radius:.5rem", + "rounded-xl": "border-radius:.75rem", + "rounded-2xl": "border-radius:1rem", + "rounded-full": "border-radius:9999px", + "rounded-t": "border-top-left-radius:.25rem;border-top-right-radius:.25rem", + "rounded-b": "border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem", + "rounded-[4px]": "border-radius:4px", + "rounded-[8px]": "border-radius:8px", + + # ── Shadow ─────────────────────────────────────────────────────────── + "shadow-sm": "box-shadow:0 1px 2px 0 rgba(0,0,0,.05)", + "shadow": "box-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1)", + "shadow-md": "box-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1)", + "shadow-lg": "box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1)", + "shadow-xl": "box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1)", + + # ── Opacity ────────────────────────────────────────────────────────── + "opacity-0": "opacity:0", + "opacity-40": "opacity:.4", + "opacity-50": "opacity:.5", + "opacity-100": "opacity:1", + + # ── Ring / Outline ─────────────────────────────────────────────────── + "outline-none": "outline:2px solid transparent;outline-offset:2px", + "ring-2": "box-shadow:0 0 0 2px var(--tw-ring-color,rgb(59 130 246))", + "ring-offset-2": "box-shadow:0 0 0 2px rgb(255 255 255),0 0 0 4px var(--tw-ring-color,rgb(59 130 246))", + + # ── Overflow ───────────────────────────────────────────────────────── + "overflow-hidden": "overflow:hidden", + "overflow-x-auto": "overflow-x:auto", + "overflow-y-auto": "overflow-y:auto", + "overscroll-contain": "overscroll-behavior:contain", + + # ── Text Decoration ────────────────────────────────────────────────── + "underline": "text-decoration-line:underline", + "line-through": "text-decoration-line:line-through", + "no-underline": "text-decoration-line:none", + + # ── Text Overflow ──────────────────────────────────────────────────── + "truncate": "overflow:hidden;text-overflow:ellipsis;white-space:nowrap", + "line-clamp-2": "display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden", + "line-clamp-3": "display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden", + + # ── Whitespace / Word Break ────────────────────────────────────────── + "whitespace-normal": "white-space:normal", + "whitespace-nowrap": "white-space:nowrap", + "whitespace-pre-line": "white-space:pre-line", + "whitespace-pre-wrap": "white-space:pre-wrap", + "break-words": "overflow-wrap:break-word", + "break-all": "word-break:break-all", + + # ── Transform ──────────────────────────────────────────────────────── + "rotate-180": "transform:rotate(180deg)", + "-translate-x-1/2": "transform:translateX(-50%)", + "-translate-y-1/2": "transform:translateY(-50%)", + + # ── Transition ─────────────────────────────────────────────────────── + "transition": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "transition-all": "transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "transition-colors": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "transition-opacity": "transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "transition-transform": "transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s", + "duration-75": "transition-duration:75ms", + "duration-100": "transition-duration:100ms", + "duration-150": "transition-duration:150ms", + "duration-200": "transition-duration:200ms", + "duration-300": "transition-duration:300ms", + "duration-500": "transition-duration:500ms", + "duration-700": "transition-duration:700ms", + + # ── Animation ──────────────────────────────────────────────────────── + "animate-spin": "animation:spin 1s linear infinite", + "animate-ping": "animation:ping 1s cubic-bezier(0,0,0.2,1) infinite", + "animate-pulse": "animation:pulse 2s cubic-bezier(0.4,0,0.6,1) infinite", + "animate-bounce": "animation:bounce 1s infinite", + "animate-none": "animation:none", + + # ── Aspect Ratio ───────────────────────────────────────────────────── + "aspect-square": "aspect-ratio:1/1", + "aspect-video": "aspect-ratio:16/9", + + # ── Object Fit / Position ──────────────────────────────────────────── + "object-contain": "object-fit:contain", + "object-cover": "object-fit:cover", + "object-center": "object-position:center", + "object-top": "object-position:top", + + # ── Cursor ─────────────────────────────────────────────────────────── + "cursor-pointer": "cursor:pointer", + "cursor-move": "cursor:move", + + # ── User Select ────────────────────────────────────────────────────── + "select-none": "user-select:none", + "select-all": "user-select:all", + + # ── Pointer Events ─────────────────────────────────────────────────── + "pointer-events-none": "pointer-events:none", + + # ── Resize ─────────────────────────────────────────────────────────── + "resize": "resize:both", + "resize-none": "resize:none", + + # ── Scroll Snap ────────────────────────────────────────────────────── + "snap-y": "scroll-snap-type:y mandatory", + "snap-start": "scroll-snap-align:start", + "snap-mandatory": "scroll-snap-type:y mandatory", + + # ── List Style ─────────────────────────────────────────────────────── + "list-disc": "list-style-type:disc", + "list-decimal": "list-style-type:decimal", + "list-inside": "list-style-position:inside", + + # ── Table ──────────────────────────────────────────────────────────── + "table-fixed": "table-layout:fixed", + + # ── Backdrop ───────────────────────────────────────────────────────── + "backdrop-blur": "backdrop-filter:blur(8px)", + "backdrop-blur-sm": "backdrop-filter:blur(4px)", + "backdrop-blur-md": "backdrop-filter:blur(12px)", + + # ── Filter ─────────────────────────────────────────────────────────── + "saturate-0": "filter:saturate(0)", + + # ── Space Between (child selector atoms) ───────────────────────────── + # These generate `.atom > :not(:first-child)` rules + "space-y-0": "margin-top:0", + "space-y-0.5": "margin-top:.125rem", + "space-y-1": "margin-top:.25rem", + "space-y-2": "margin-top:.5rem", + "space-y-3": "margin-top:.75rem", + "space-y-4": "margin-top:1rem", + "space-y-6": "margin-top:1.5rem", + "space-y-8": "margin-top:2rem", + "space-y-10": "margin-top:2.5rem", + "space-x-1": "margin-left:.25rem", + "space-x-2": "margin-left:.5rem", + + # ── Divide (child selector atoms) ──────────────────────────────────── + # These generate `.atom > :not(:first-child)` rules + "divide-y": "border-top-width:1px", + "divide-stone-100": "border-color:rgb(245 245 244)", + "divide-stone-200": "border-color:rgb(231 229 228)", + + # ── Important modifiers ────────────────────────────────────────────── + "!bg-stone-500": "background-color:rgb(120 113 108)!important", + "!text-white": "color:rgb(255 255 255)!important", +} + +# Atoms that need a child selector: `.atom > :not(:first-child)` instead of `.atom` +CHILD_SELECTOR_ATOMS: frozenset[str] = frozenset({ + k for k in STYLE_ATOMS + if k.startswith(("space-x-", "space-y-", "divide-y", "divide-x")) + and not k.startswith("divide-stone") +}) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Pseudo-class / pseudo-element variants +# ═══════════════════════════════════════════════════════════════════════════ + +PSEUDO_VARIANTS: dict[str, str] = { + "hover": ":hover", + "focus": ":focus", + "focus-within": ":focus-within", + "focus-visible": ":focus-visible", + "active": ":active", + "disabled": ":disabled", + "first": ":first-child", + "last": ":last-child", + "odd": ":nth-child(odd)", + "even": ":nth-child(even)", + "empty": ":empty", + "open": "[open]", + "placeholder": "::placeholder", + "file": "::file-selector-button", + "aria-selected": "[aria-selected=true]", + "group-hover": ":is(.group:hover) &", + "group-open": ":is(.group[open]) &", +} + + +# ═══════════════════════════════════════════════════════════════════════════ +# Responsive breakpoints +# ═══════════════════════════════════════════════════════════════════════════ + +RESPONSIVE_BREAKPOINTS: dict[str, str] = { + "sm": "(min-width:640px)", + "md": "(min-width:768px)", + "lg": "(min-width:1024px)", + "xl": "(min-width:1280px)", + "2xl": "(min-width:1536px)", +} + + +# ═══════════════════════════════════════════════════════════════════════════ +# Keyframes — built-in animation definitions +# ═══════════════════════════════════════════════════════════════════════════ + +KEYFRAMES: dict[str, str] = { + "spin": "@keyframes spin{to{transform:rotate(360deg)}}", + "ping": "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}", + "pulse": "@keyframes pulse{50%{opacity:.5}}", + "bounce": "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}", +} + + +# ═══════════════════════════════════════════════════════════════════════════ +# Arbitrary value patterns — fallback when atom not in STYLE_ATOMS +# ═══════════════════════════════════════════════════════════════════════════ +# +# Each tuple is (regex_pattern, css_template). +# The regex captures value groups; the template uses {0}, {1}, etc. + +ARBITRARY_PATTERNS: list[tuple[str, str]] = [ + # Width / Height + (r"w-\[(.+)\]", "width:{0}"), + (r"h-\[(.+)\]", "height:{0}"), + (r"min-w-\[(.+)\]", "min-width:{0}"), + (r"min-h-\[(.+)\]", "min-height:{0}"), + (r"max-w-\[(.+)\]", "max-width:{0}"), + (r"max-h-\[(.+)\]", "max-height:{0}"), + # Spacing + (r"p-\[(.+)\]", "padding:{0}"), + (r"px-\[(.+)\]", "padding-left:{0};padding-right:{0}"), + (r"py-\[(.+)\]", "padding-top:{0};padding-bottom:{0}"), + (r"pt-\[(.+)\]", "padding-top:{0}"), + (r"pb-\[(.+)\]", "padding-bottom:{0}"), + (r"pl-\[(.+)\]", "padding-left:{0}"), + (r"pr-\[(.+)\]", "padding-right:{0}"), + (r"m-\[(.+)\]", "margin:{0}"), + (r"mx-\[(.+)\]", "margin-left:{0};margin-right:{0}"), + (r"my-\[(.+)\]", "margin-top:{0};margin-bottom:{0}"), + (r"mt-\[(.+)\]", "margin-top:{0}"), + (r"mb-\[(.+)\]", "margin-bottom:{0}"), + (r"ml-\[(.+)\]", "margin-left:{0}"), + (r"mr-\[(.+)\]", "margin-right:{0}"), + # Gap + (r"gap-\[(.+)\]", "gap:{0}"), + (r"gap-x-\[(.+)\]", "column-gap:{0}"), + (r"gap-y-\[(.+)\]", "row-gap:{0}"), + # Position + (r"top-\[(.+)\]", "top:{0}"), + (r"right-\[(.+)\]", "right:{0}"), + (r"bottom-\[(.+)\]", "bottom:{0}"), + (r"left-\[(.+)\]", "left:{0}"), + # Border radius + (r"rounded-\[(.+)\]", "border-radius:{0}"), + # Background / Text color + (r"bg-\[(.+)\]", "background-color:{0}"), + (r"text-\[(.+)\]", "font-size:{0}"), + # Grid + (r"grid-cols-\[(.+)\]", "grid-template-columns:{0}"), + (r"col-span-(\d+)", "grid-column:span {0}/span {0}"), +] diff --git a/shared/sx/style_resolver.py b/shared/sx/style_resolver.py new file mode 100644 index 0000000..eb4ffdd --- /dev/null +++ b/shared/sx/style_resolver.py @@ -0,0 +1,254 @@ +""" +Style resolver — ``(css :flex :gap-4 :hover:bg-sky-200)`` → StyleValue. + +Resolves a tuple of atom strings into a ``StyleValue`` with: +- A content-addressed class name (``sx-{hash[:6]}``) +- Base CSS declarations +- Pseudo-class rules (hover, focus, etc.) +- Media-query rules (responsive breakpoints) +- Referenced @keyframes definitions + +Resolution order per atom: + 1. Dictionary lookup in ``STYLE_ATOMS`` + 2. Arbitrary value pattern match (``w-[347px]`` → ``width:347px``) + 3. Ignored (unknown atoms are silently skipped) + +Results are memoized by input tuple for zero-cost repeat calls. +""" +from __future__ import annotations + +import hashlib +import re +from functools import lru_cache +from typing import Sequence + +from .style_dict import ( + ARBITRARY_PATTERNS, + CHILD_SELECTOR_ATOMS, + KEYFRAMES, + PSEUDO_VARIANTS, + RESPONSIVE_BREAKPOINTS, + STYLE_ATOMS, +) +from .types import StyleValue + + +# --------------------------------------------------------------------------- +# Compiled arbitrary-value patterns +# --------------------------------------------------------------------------- + +_COMPILED_PATTERNS: list[tuple[re.Pattern, str]] = [ + (re.compile(f"^{pat}$"), tmpl) + for pat, tmpl in ARBITRARY_PATTERNS +] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def resolve_style(atoms: tuple[str, ...]) -> StyleValue: + """Resolve a tuple of keyword atoms into a StyleValue. + + Each atom is a Tailwind-compatible keyword (``flex``, ``gap-4``, + ``hover:bg-sky-200``, ``sm:flex-row``, etc.). Both keywords + (without leading colon) and runtime strings are accepted. + """ + return _resolve_cached(atoms) + + +def merge_styles(styles: Sequence[StyleValue]) -> StyleValue: + """Merge multiple StyleValues into one. + + Later declarations win for the same CSS property. Class name is + recomputed from the merged declarations. + """ + if len(styles) == 1: + return styles[0] + + all_decls: list[str] = [] + all_media: list[tuple[str, str]] = [] + all_pseudo: list[tuple[str, str]] = [] + all_kf: list[tuple[str, str]] = [] + + for sv in styles: + if sv.declarations: + all_decls.append(sv.declarations) + all_media.extend(sv.media_rules) + all_pseudo.extend(sv.pseudo_rules) + all_kf.extend(sv.keyframes) + + merged_decls = ";".join(all_decls) + return _build_style_value( + merged_decls, + tuple(all_media), + tuple(all_pseudo), + tuple(dict(all_kf).items()), # dedupe keyframes by name + ) + + +# --------------------------------------------------------------------------- +# Internal resolution +# --------------------------------------------------------------------------- + +@lru_cache(maxsize=4096) +def _resolve_cached(atoms: tuple[str, ...]) -> StyleValue: + """Memoized resolver.""" + base_decls: list[str] = [] + media_rules: list[tuple[str, str]] = [] # (query, decls) + pseudo_rules: list[tuple[str, str]] = [] # (selector_suffix, decls) + keyframes_needed: list[tuple[str, str]] = [] + + for atom in atoms: + if not atom: + continue + # Strip leading colon if keyword form (":flex" → "flex") + a = atom.lstrip(":") + + # Split variant prefix(es): "hover:bg-sky-200" → ["hover", "bg-sky-200"] + # "sm:hover:bg-sky-200" → ["sm", "hover", "bg-sky-200"] + variant, base = _split_variant(a) + + # Resolve the base atom to CSS declarations + decls = _resolve_atom(base) + if not decls: + continue + + # Check if this atom references a keyframe + _check_keyframes(base, keyframes_needed) + + # Route to the appropriate bucket + if variant is None: + base_decls.append(decls) + elif variant in RESPONSIVE_BREAKPOINTS: + query = RESPONSIVE_BREAKPOINTS[variant] + media_rules.append((query, decls)) + elif variant in PSEUDO_VARIANTS: + pseudo_sel = PSEUDO_VARIANTS[variant] + pseudo_rules.append((pseudo_sel, decls)) + else: + # Compound variant: "sm:hover:..." → media + pseudo + parts = variant.split(":") + media_part = None + pseudo_part = None + for p in parts: + if p in RESPONSIVE_BREAKPOINTS: + media_part = RESPONSIVE_BREAKPOINTS[p] + elif p in PSEUDO_VARIANTS: + pseudo_part = PSEUDO_VARIANTS[p] + if media_part and pseudo_part: + # Both media and pseudo — store as pseudo within media + # For now, put in pseudo_rules with media annotation + pseudo_rules.append((pseudo_part, decls)) + media_rules.append((media_part, decls)) + elif media_part: + media_rules.append((media_part, decls)) + elif pseudo_part: + pseudo_rules.append((pseudo_part, decls)) + else: + # Unknown variant — treat as base + base_decls.append(decls) + + return _build_style_value( + ";".join(base_decls), + tuple(media_rules), + tuple(pseudo_rules), + tuple(keyframes_needed), + ) + + +def _split_variant(atom: str) -> tuple[str | None, str]: + """Split a potentially variant-prefixed atom. + + Returns (variant, base) where variant is None for non-prefixed atoms. + Examples: + "flex" → (None, "flex") + "hover:bg-sky-200" → ("hover", "bg-sky-200") + "sm:flex-row" → ("sm", "flex-row") + "sm:hover:bg-sky-200" → ("sm:hover", "bg-sky-200") + """ + # Check for responsive prefix first (always outermost) + for bp in RESPONSIVE_BREAKPOINTS: + prefix = bp + ":" + if atom.startswith(prefix): + rest = atom[len(prefix):] + # Check for nested pseudo variant + for pv in PSEUDO_VARIANTS: + inner_prefix = pv + ":" + if rest.startswith(inner_prefix): + return (bp + ":" + pv, rest[len(inner_prefix):]) + return (bp, rest) + + # Check for pseudo variant + for pv in PSEUDO_VARIANTS: + prefix = pv + ":" + if atom.startswith(prefix): + return (pv, atom[len(prefix):]) + + return (None, atom) + + +def _resolve_atom(atom: str) -> str | None: + """Look up CSS declarations for a single base atom. + + Returns None if the atom is unknown. + """ + # 1. Dictionary lookup + decls = STYLE_ATOMS.get(atom) + if decls is not None: + return decls + + # 2. Dynamic keyframes: animate-{name} → animation-name:{name} + if atom.startswith("animate-"): + name = atom[len("animate-"):] + if name in KEYFRAMES: + return f"animation-name:{name}" + + # 3. Arbitrary value pattern match + for pattern, template in _COMPILED_PATTERNS: + m = pattern.match(atom) + if m: + groups = m.groups() + result = template + for i, g in enumerate(groups): + result = result.replace(f"{{{i}}}", g) + return result + + # 4. Unknown atom — silently skip + return None + + +def _check_keyframes(atom: str, kf_list: list[tuple[str, str]]) -> None: + """If the atom references a built-in animation, add its @keyframes.""" + if atom.startswith("animate-"): + name = atom[len("animate-"):] + if name in KEYFRAMES: + kf_list.append((name, KEYFRAMES[name])) + + +def _build_style_value( + declarations: str, + media_rules: tuple, + pseudo_rules: tuple, + keyframes: tuple, +) -> StyleValue: + """Build a StyleValue with a content-addressed class name.""" + # Build hash from all rules for deterministic class name + hash_input = declarations + for query, decls in media_rules: + hash_input += f"@{query}{{{decls}}}" + for sel, decls in pseudo_rules: + hash_input += f"{sel}{{{decls}}}" + for name, rule in keyframes: + hash_input += rule + + h = hashlib.sha256(hash_input.encode()).hexdigest()[:6] + class_name = f"sx-{h}" + + return StyleValue( + class_name=class_name, + declarations=declarations, + media_rules=media_rules, + pseudo_rules=pseudo_rules, + keyframes=keyframes, + ) diff --git a/shared/sx/types.py b/shared/sx/types.py index 1613c08..dbe1e19 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -278,9 +278,33 @@ class ActionDef: return f"" +# --------------------------------------------------------------------------- +# StyleValue +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class StyleValue: + """A resolved CSS style produced by ``(css :flex :gap-4 :hover:bg-sky-200)``. + + Generated by the style resolver. The renderer emits ``class_name`` as a + CSS class and registers the CSS rule for on-demand delivery. + """ + class_name: str # "sx-a3f2c1" + declarations: str # "display:flex;gap:1rem" + media_rules: tuple = () # ((query, decls), ...) + pseudo_rules: tuple = () # ((selector, decls), ...) + keyframes: tuple = () # (("spin", "@keyframes spin{...}"), ...) + + def __repr__(self): + return f"" + + def __str__(self): + return self.class_name + + # --------------------------------------------------------------------------- # Type alias # --------------------------------------------------------------------------- # An s-expression value after evaluation -SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None +SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | StyleValue | list | dict | _Nil | None