Files
rose-ash/shared/sx/style_resolver.py
giles 19d59f5f4b
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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>
2026-03-04 12:47:51 +00:00

255 lines
8.1 KiB
Python

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