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();