Implement CSSX Phase 2: native SX style primitives
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 12:47:51 +00:00
parent 28388540d5
commit 19d59f5f4b
11 changed files with 1660 additions and 7 deletions

View File

@@ -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 <style>)
function _loadStyleDict(data) {
_styleAtoms = data.a || {};
_pseudoVariants = data.v || {};
_responsiveBreakpoints = data.b || {};
_styleKeyframes = data.k || {};
_childSelectorPrefixes = data.c || [];
_arbitraryPatterns = [];
var pats = data.p || [];
for (var i = 0; i < pats.length; i++) {
_arbitraryPatterns.push({ re: new RegExp("^" + pats[i][0] + "$"), tmpl: pats[i][1] });
}
_styleCache = {};
}
function _splitVariant(atom) {
// Check responsive prefix first
for (var bp in _responsiveBreakpoints) {
var prefix = bp + ":";
if (atom.indexOf(prefix) === 0) {
var rest = atom.substring(prefix.length);
for (var pv in _pseudoVariants) {
var inner = pv + ":";
if (rest.indexOf(inner) === 0) return [bp + ":" + pv, rest.substring(inner.length)];
}
return [bp, rest];
}
}
for (var pv2 in _pseudoVariants) {
var prefix2 = pv2 + ":";
if (atom.indexOf(prefix2) === 0) return [pv2, atom.substring(prefix2.length)];
}
return [null, atom];
}
function _resolveAtom(atom) {
var decls = _styleAtoms[atom];
if (decls !== undefined) return decls;
// Dynamic keyframes: animate-{name} → animation-name:{name}
if (atom.indexOf("animate-") === 0) {
var kfName = atom.substring(8);
if (_styleKeyframes[kfName]) return "animation-name:" + kfName;
}
for (var i = 0; i < _arbitraryPatterns.length; i++) {
var m = atom.match(_arbitraryPatterns[i].re);
if (m) {
var result = _arbitraryPatterns[i].tmpl;
for (var j = 1; j < m.length; j++) result = result.replace("{" + (j - 1) + "}", m[j]);
return result;
}
}
return null;
}
function _isChildSelectorAtom(atom) {
for (var i = 0; i < _childSelectorPrefixes.length; i++) {
if (atom.indexOf(_childSelectorPrefixes[i]) === 0) return true;
}
return false;
}
/** SHA-256 hash (first 6 hex chars) for content-addressed class names. */
function _hashStyle(input) {
// Simple FNV-1a 32-bit hash — fast, deterministic, good distribution
var h = 0x811c9dc5;
for (var i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, "0").substring(0, 6);
}
function _resolveStyle(atoms) {
var key = atoms.join("\0");
if (_styleCache[key]) return _styleCache[key];
var baseDecls = [], mediaRules = [], pseudoRules = [], kfNeeded = [];
for (var i = 0; i < atoms.length; i++) {
var a = atoms[i];
if (!a) continue;
if (a.charAt(0) === ":") a = a.substring(1);
var parts = _splitVariant(a);
var variant = parts[0], base = parts[1];
var decls = _resolveAtom(base);
if (!decls) continue;
// Check keyframes
if (base.indexOf("animate-") === 0) {
var kfName = base.substring(8);
if (_styleKeyframes[kfName]) kfNeeded.push([kfName, _styleKeyframes[kfName]]);
}
if (variant === null) {
baseDecls.push(decls);
} else if (_responsiveBreakpoints[variant]) {
mediaRules.push([_responsiveBreakpoints[variant], decls]);
} else if (_pseudoVariants[variant]) {
pseudoRules.push([_pseudoVariants[variant], decls]);
} else {
// Compound variant: "sm:hover" → split
var vparts = variant.split(":");
var mediaPart = null, pseudoPart = null;
for (var vi = 0; vi < vparts.length; vi++) {
if (_responsiveBreakpoints[vparts[vi]]) mediaPart = _responsiveBreakpoints[vparts[vi]];
else if (_pseudoVariants[vparts[vi]]) pseudoPart = _pseudoVariants[vparts[vi]];
}
if (mediaPart) mediaRules.push([mediaPart, decls]);
if (pseudoPart) pseudoRules.push([pseudoPart, decls]);
if (!mediaPart && !pseudoPart) baseDecls.push(decls);
}
}
// Build hash input
var hashInput = baseDecls.join(";");
for (var mi = 0; mi < mediaRules.length; mi++) hashInput += "@" + mediaRules[mi][0] + "{" + mediaRules[mi][1] + "}";
for (var pi = 0; pi < pseudoRules.length; pi++) hashInput += pseudoRules[pi][0] + "{" + pseudoRules[pi][1] + "}";
for (var ki = 0; ki < kfNeeded.length; ki++) hashInput += kfNeeded[ki][1];
var cn = "sx-" + _hashStyle(hashInput);
var sv = new StyleValue(cn, baseDecls.join(";"), mediaRules, pseudoRules, kfNeeded);
_styleCache[key] = sv;
// Inject CSS rules into <style id="sx-css">
_injectStyleValue(sv, atoms);
return sv;
}
function _injectStyleValue(sv, atoms) {
if (_injectedStyles[sv.className]) return;
_injectedStyles[sv.className] = true;
var cssTarget = document.getElementById("sx-css");
if (!cssTarget) return;
var rules = [];
if (sv.declarations) {
// Check if any atoms need child selectors
var hasChild = false;
if (atoms) {
for (var ai = 0; ai < atoms.length; ai++) {
if (_isChildSelectorAtom(atoms[ai])) { hasChild = true; break; }
}
}
if (hasChild) {
rules.push("." + sv.className + ">:not(:first-child){" + sv.declarations + "}");
} else {
rules.push("." + sv.className + "{" + sv.declarations + "}");
}
}
for (var pi = 0; pi < sv.pseudoRules.length; pi++) {
var sel = sv.pseudoRules[pi][0], decls = sv.pseudoRules[pi][1];
if (sel.indexOf("&") >= 0) {
rules.push(sel.replace(/&/g, "." + sv.className) + "{" + decls + "}");
} else {
rules.push("." + sv.className + sel + "{" + decls + "}");
}
}
for (var mi = 0; mi < sv.mediaRules.length; mi++) {
rules.push("@media " + sv.mediaRules[mi][0] + "{." + sv.className + "{" + sv.mediaRules[mi][1] + "}}");
}
for (var ki = 0; ki < sv.keyframes.length; ki++) {
rules.push(sv.keyframes[ki][1]);
}
cssTarget.textContent += rules.join("");
}
function _mergeStyleValues(styles) {
if (styles.length === 1) return styles[0];
var allDecls = [], allMedia = [], allPseudo = [], allKf = [];
var allAtoms = [];
for (var i = 0; i < styles.length; i++) {
var sv = styles[i];
if (sv.declarations) allDecls.push(sv.declarations);
allMedia = allMedia.concat(sv.mediaRules);
allPseudo = allPseudo.concat(sv.pseudoRules);
allKf = allKf.concat(sv.keyframes);
}
var hashInput = allDecls.join(";");
for (var mi = 0; mi < allMedia.length; mi++) hashInput += "@" + allMedia[mi][0] + "{" + allMedia[mi][1] + "}";
for (var pi = 0; pi < allPseudo.length; pi++) hashInput += allPseudo[pi][0] + "{" + allPseudo[pi][1] + "}";
for (var ki = 0; ki < allKf.length; ki++) hashInput += allKf[ki][1];
var cn = "sx-" + _hashStyle(hashInput);
var merged = new StyleValue(cn, allDecls.join(";"), allMedia, allPseudo, allKf);
_injectStyleValue(merged, allAtoms);
return merged;
}
// css primitive: (css :flex :gap-4 :hover:bg-sky-200) → StyleValue
PRIMITIVES["css"] = function () {
var atoms = [];
for (var i = 0; i < arguments.length; i++) {
var a = arguments[i];
if (isNil(a) || a === false) continue;
atoms.push(isKw(a) ? a.name : String(a));
}
if (!atoms.length) return NIL;
return _resolveStyle(atoms);
};
// merge-styles: combine multiple StyleValues
PRIMITIVES["merge-styles"] = function () {
var valid = [];
for (var i = 0; i < arguments.length; i++) {
if (isStyleValue(arguments[i])) valid.push(arguments[i]);
}
if (!valid.length) return NIL;
if (valid.length === 1) return valid[0];
return _mergeStyleValues(valid);
};
// --- Evaluator ---
/** Unwrap thunks by re-entering the evaluator until we get an actual value. */
@@ -583,6 +815,37 @@
return value;
};
SPECIAL_FORMS["defstyle"] = function (expr, env) {
var name = expr[1].name;
var value = sxEval(expr[2], env);
env[name] = value;
return value;
};
SPECIAL_FORMS["defkeyframes"] = function (expr, env) {
var kfName = expr[1].name;
var steps = [];
for (var i = 2; i < expr.length; i++) {
var step = expr[i];
var selector = isSym(step[0]) ? step[0].name : String(step[0]);
var body = sxEval(step[1], env);
var decls = isStyleValue(body) ? body.declarations : String(body);
steps.push(selector + "{" + decls + "}");
}
var kfRule = "@keyframes " + kfName + "{" + steps.join("") + "}";
// Register in keyframes dict for animate-{name} lookup
_styleKeyframes[kfName] = kfRule;
_styleCache = {}; // Clear cache so new keyframes are picked up
// Build a StyleValue for the animation
var cn = "sx-" + _hashStyle(kfRule);
var sv = new StyleValue(cn, "animation-name:" + kfName, [], [], [[kfName, kfRule]]);
_injectStyleValue(sv, []);
env[kfName] = sv;
return sv;
};
SPECIAL_FORMS["defcomp"] = function (expr, env) {
var nameSym = expr[1];
var compName = nameSym.name.replace(/^~/, "");
@@ -859,6 +1122,8 @@
};
RENDER_FORMS["define"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defstyle"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defkeyframes"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defcomp"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defmacro"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
RENDER_FORMS["defhandler"] = function (expr, env) { sxEval(expr, env); return document.createDocumentFragment(); };
@@ -1044,6 +1309,7 @@
? document.createElementNS(SVG_NS, tag)
: document.createElement(tag);
var extraClass = null;
var i = 0;
while (i < args.length) {
var arg = args[i];
@@ -1052,6 +1318,11 @@
var attrVal = sxEval(args[i + 1], env);
i += 2;
if (isNil(attrVal) || attrVal === false) continue;
// :style StyleValue → convert to class
if (attrName === "style" && isStyleValue(attrVal)) {
extraClass = attrVal.className;
continue;
}
if (BOOLEAN_ATTRS[attrName]) {
if (attrVal) el.setAttribute(attrName, "");
} else if (attrVal === true) {
@@ -1068,6 +1339,12 @@
}
}
// Merge StyleValue class into element's class attribute
if (extraClass) {
var existing = el.getAttribute("class");
el.setAttribute("class", existing ? existing + " " + extraClass : extraClass);
}
return el;
}
@@ -1361,7 +1638,7 @@
},
// For testing / sx-test.js
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML },
_types: { NIL: NIL, Symbol: Symbol, Keyword: Keyword, Lambda: Lambda, Component: Component, RawHTML: RawHTML, StyleValue: StyleValue },
_eval: sxEval,
_expandMacro: expandMacro,
_callLambda: function (fn, args, env) { return trampoline(callLambda(fn, args, env)); },
@@ -2664,7 +2941,7 @@
// --- Auto-init in browser ---
Sx.VERSION = "2026-03-01c-cssx";
Sx.VERSION = "2026-03-04-cssx2";
// CSS class tracking for on-demand CSS delivery
var _sxCssHash = ""; // 8-char hex hash from server
@@ -2707,10 +2984,79 @@
document.cookie = "sx-comp-hash=;path=/;max-age=0;SameSite=Lax";
}
// --- sx-styles-hash cookie helpers ---
function _setSxStylesCookie(hash) {
document.cookie = "sx-styles-hash=" + hash + ";path=/;max-age=31536000;SameSite=Lax";
}
function _clearSxStylesCookie() {
document.cookie = "sx-styles-hash=;path=/;max-age=0;SameSite=Lax";
}
function _initStyleDict() {
var scripts = document.querySelectorAll('script[type="text/sx-styles"]');
for (var i = 0; i < scripts.length; i++) {
var s = scripts[i];
if (s._sxProcessed) continue;
s._sxProcessed = true;
var text = s.textContent;
var hash = s.getAttribute("data-hash");
if (!hash) {
if (text && text.trim()) {
try { _loadStyleDict(JSON.parse(text)); } catch (e) { console.warn("[sx.js] style dict parse error", e); }
}
continue;
}
var hasInline = text && text.trim();
try {
var cachedHash = localStorage.getItem("sx-styles-hash");
if (cachedHash === hash) {
if (hasInline) {
localStorage.setItem("sx-styles-src", text);
_loadStyleDict(JSON.parse(text));
console.log("[sx.js] styles: downloaded (cookie stale)");
} else {
var cached = localStorage.getItem("sx-styles-src");
if (cached) {
_loadStyleDict(JSON.parse(cached));
console.log("[sx.js] styles: cached (" + hash + ")");
} else {
_clearSxStylesCookie();
location.reload();
return;
}
}
} else {
if (hasInline) {
localStorage.setItem("sx-styles-hash", hash);
localStorage.setItem("sx-styles-src", text);
_loadStyleDict(JSON.parse(text));
console.log("[sx.js] styles: downloaded (" + hash + ")");
} else {
localStorage.removeItem("sx-styles-hash");
localStorage.removeItem("sx-styles-src");
_clearSxStylesCookie();
location.reload();
return;
}
}
} catch (e) {
if (hasInline) {
try { _loadStyleDict(JSON.parse(text)); } catch (e2) { console.warn("[sx.js] style dict parse error", e2); }
}
}
_setSxStylesCookie(hash);
}
}
if (typeof document !== "undefined") {
var init = function () {
console.log("[sx.js] v" + Sx.VERSION + " init");
_initCssTracking();
_initStyleDict();
Sx.processScripts();
Sx.hydrate();
SxEngine.process();

View File

@@ -22,7 +22,7 @@ from __future__ import annotations
import inspect
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 _expand_macro, EvalError
from .primitives import _PRIMITIVES
from .primitives_io import IO_PRIMITIVES, RequestContext, execute_io
@@ -294,6 +294,16 @@ async def _asf_defcomp(expr, env, ctx):
return _sf_defcomp(expr, env)
async def _asf_defstyle(expr, env, ctx):
from .evaluator import _sf_defstyle
return _sf_defstyle(expr, env)
async def _asf_defkeyframes(expr, env, ctx):
from .evaluator import _sf_defkeyframes
return _sf_defkeyframes(expr, env)
async def _asf_defmacro(expr, env, ctx):
from .evaluator import _sf_defmacro
return _sf_defmacro(expr, env)
@@ -430,6 +440,8 @@ _ASYNC_SPECIAL_FORMS: dict[str, Any] = {
"lambda": _asf_lambda,
"fn": _asf_lambda,
"define": _asf_define,
"defstyle": _asf_defstyle,
"defkeyframes": _asf_defkeyframes,
"defcomp": _asf_defcomp,
"defmacro": _asf_defmacro,
"defhandler": _asf_defhandler,
@@ -684,6 +696,18 @@ async def _arender_element(
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)
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"]
class_val = attrs.get("class")
if class_val is not None and class_val is not NIL and class_val is not False:
collector = css_class_collector.get(None)
@@ -897,6 +921,8 @@ _ASYNC_RENDER_FORMS: dict[str, Any] = {
"begin": _arsf_begin,
"do": _arsf_begin,
"define": _arsf_define,
"defstyle": _arsf_define,
"defkeyframes": _arsf_define,
"defcomp": _arsf_define,
"defmacro": _arsf_define,
"defhandler": _arsf_define,
@@ -1125,23 +1151,54 @@ async def _aser_call(
"""Serialize ``(name :key val child ...)`` — evaluate args but keep
as sx source instead of rendering to HTML."""
parts = [name]
extra_class: str | None = None # from :style StyleValue conversion
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
val = await _aser(args[i + 1], env, ctx)
if val is not NIL and val is not None:
parts.append(f":{arg.name}")
parts.append(serialize(val))
# :style StyleValue → convert to :class and register CSS
if arg.name == "style" and isinstance(val, StyleValue):
from .css_registry import register_generated_rule
register_generated_rule(val)
extra_class = val.class_name
else:
parts.append(f":{arg.name}")
parts.append(serialize(val))
i += 2
else:
result = await _aser(arg, env, ctx)
if result is not NIL and result is not None:
parts.append(serialize(result))
i += 1
# If we converted a :style to a class, merge into existing :class or add it
if extra_class:
_merge_class_into_parts(parts, extra_class)
return SxExpr("(" + " ".join(parts) + ")")
def _merge_class_into_parts(parts: list[str], class_name: str) -> None:
"""Merge an extra class name into the serialized parts list.
If :class already exists, append to it. Otherwise add :class.
"""
for i, p in enumerate(parts):
if p == ":class" and i + 1 < len(parts):
# Existing :class — append our class
existing = parts[i + 1]
if existing.startswith('"') and existing.endswith('"'):
# Quoted string — insert before closing quote
parts[i + 1] = existing[:-1] + " " + class_name + '"'
else:
# Expression — wrap in (str ...)
parts[i + 1] = f'(str {existing} " {class_name}")'
return
# No existing :class — add one
parts.insert(1, f'"{class_name}"')
parts.insert(1, ":class")
# ---------------------------------------------------------------------------
# Serialize-mode special forms
# ---------------------------------------------------------------------------
@@ -1347,6 +1404,8 @@ _ASER_FORMS: dict[str, Any] = {
"lambda": _assf_lambda,
"fn": _assf_lambda,
"define": _assf_define,
"defstyle": _assf_define,
"defkeyframes": _assf_define,
"defcomp": _assf_define,
"defmacro": _assf_define,
"defhandler": _assf_define,

View File

@@ -147,6 +147,48 @@ def scan_classes_from_sx(source: str) -> set[str]:
return classes
def register_generated_rule(style_val: Any) -> None:
"""Register a generated StyleValue's CSS rules in the registry.
This allows generated class names (``sx-a3f2c1``) to flow through
the existing ``lookup_rules()`` → ``SX-Css`` delta pipeline.
"""
from .style_dict import CHILD_SELECTOR_ATOMS
cn = style_val.class_name
if cn in _REGISTRY:
return # already registered
parts: list[str] = []
# Base declarations
if style_val.declarations:
parts.append(f".{cn}{{{style_val.declarations}}}")
# Pseudo-class rules
for sel, decls in style_val.pseudo_rules:
if sel.startswith("::"):
parts.append(f".{cn}{sel}{{{decls}}}")
elif "&" in sel:
# group-hover pattern: ":is(.group:hover) &" → .group:hover .sx-abc
expanded = sel.replace("&", f".{cn}")
parts.append(f"{expanded}{{{decls}}}")
else:
parts.append(f".{cn}{sel}{{{decls}}}")
# Media-query rules
for query, decls in style_val.media_rules:
parts.append(f"@media {query}{{.{cn}{{{decls}}}}}")
# Keyframes
for _name, kf_rule in style_val.keyframes:
parts.append(kf_rule)
rule_text = "".join(parts)
order = len(_RULE_ORDER) + 10000 # after all tw.css rules
_REGISTRY[cn] = rule_text
_RULE_ORDER[cn] = order
def registry_loaded() -> bool:
"""True if the registry has been populated."""
return bool(_REGISTRY)

View File

@@ -366,6 +366,78 @@ def _sf_define(expr: list, env: dict) -> Any:
return value
def _sf_defstyle(expr: list, env: dict) -> Any:
"""``(defstyle card-base (css :rounded-xl :bg-white :shadow))``
Evaluates body → StyleValue, binds to name in env.
"""
if len(expr) < 3:
raise EvalError("defstyle requires name and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defstyle name must be symbol, got {type(name_sym).__name__}")
value = _trampoline(_eval(expr[2], env))
env[name_sym.name] = value
return value
def _sf_defkeyframes(expr: list, env: dict) -> Any:
"""``(defkeyframes fade-in (from (css :opacity-0)) (to (css :opacity-100)))``
Builds @keyframes rule from steps, registers it, and binds the animation.
"""
from .types import StyleValue
from .css_registry import register_generated_rule
from .style_dict import KEYFRAMES
if len(expr) < 3:
raise EvalError("defkeyframes requires name and at least one step")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defkeyframes name must be symbol, got {type(name_sym).__name__}")
kf_name = name_sym.name
# Build @keyframes rule from steps
steps: list[str] = []
for step_expr in expr[2:]:
if not isinstance(step_expr, list) or len(step_expr) < 2:
raise EvalError("defkeyframes step must be (selector (css ...))")
selector = step_expr[0]
if isinstance(selector, Symbol):
selector = selector.name
else:
selector = str(selector)
body = _trampoline(_eval(step_expr[1], env))
if isinstance(body, StyleValue):
decls = body.declarations
elif isinstance(body, str):
decls = body
else:
raise EvalError(f"defkeyframes step body must be css/string, got {type(body).__name__}")
steps.append(f"{selector}{{{decls}}}")
kf_rule = f"@keyframes {kf_name}{{{' '.join(steps)}}}"
# Register in KEYFRAMES so animate-{name} works
KEYFRAMES[kf_name] = kf_rule
# Clear resolver cache so new keyframes are picked up
from .style_resolver import _resolve_cached
_resolve_cached.cache_clear()
# Create a StyleValue for the animation property
import hashlib
h = hashlib.sha256(kf_rule.encode()).hexdigest()[:6]
sv = StyleValue(
class_name=f"sx-{h}",
declarations=f"animation-name:{kf_name}",
keyframes=((kf_name, kf_rule),),
)
register_generated_rule(sv)
env[kf_name] = sv
return sv
def _sf_defcomp(expr: list, env: dict) -> Component:
"""``(defcomp ~name (&key param1 param2 &rest children) body)``"""
if len(expr) < 4:
@@ -814,6 +886,8 @@ _SPECIAL_FORMS: dict[str, Any] = {
"lambda": _sf_lambda,
"fn": _sf_lambda,
"define": _sf_define,
"defstyle": _sf_defstyle,
"defkeyframes": _sf_defkeyframes,
"defcomp": _sf_defcomp,
"defrelation": _sf_defrelation,
"begin": _sf_begin,

View File

@@ -611,6 +611,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.
</style>
</head>
<body class="bg-stone-50 text-stone-900">
<script type="text/sx-styles" data-hash="{styles_hash}">{styles_json}</script>
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx.js?v={sx_js_hash}"></script>
@@ -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:

View File

@@ -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:

View File

@@ -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):

View File

@@ -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)

735
shared/sx/style_dict.py Normal file
View File

@@ -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}"),
]

254
shared/sx/style_resolver.py Normal file
View File

@@ -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,
)

View File

@@ -278,9 +278,33 @@ class ActionDef:
return f"<action:{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# 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"<StyleValue {self.class_name}>"
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