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 <noreply@anthropic.com>
This commit is contained in:
254
shared/sx/style_resolver.py
Normal file
254
shared/sx/style_resolver.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user