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

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