Files
mono/docs/cssx.md
giles 03f9968979 Add @ rules, dynamic generation, and arbitrary values to SX styles plan
Cover @keyframes (defkeyframes special form + built-in animations),
@container queries, dynamic atom construction (no server round-trip
since client has full dictionary), arbitrary bracket values (w-[347px]),
and inline style fallback for truly unique data-driven values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:05:25 +00:00

29 KiB
Raw Blame History

On-Demand CSS System — Replace Tailwind with Server-Driven Style Delivery

Context

The app recently moved from Tailwind CDN to a pre-built tw.css (92KB, Tailwind v4), but v4 broke styles since the classes target v3. Currently reverted to CDN for dev. Rather than fixing the v3/v4 mismatch, we replace Tailwind entirely with an on-demand CSS system: the server knows exactly which classes each response uses (because it renders sx→HTML), so it sends only the CSS rules needed — zero unused CSS, zero build step, zero Tailwind dependency.

This mirrors the existing SX-Components dedup protocol: client tells server what it has, server sends only what's new.

Phase 1: Minimal Viable System

Step 1: CSS Registry — shared/sx/css_registry.py (new file)

Parse tw.css at startup into a dict mapping HTML class names → CSS rule text.

  • load_css_registry(path) — parse tw.css once at startup, populate module-level _REGISTRY: dict[str, str] and _PREAMBLE: str (the @property / @layer declarations that define --tw-* vars)
  • _css_selector_to_class(selector) — unescape CSS selectors (.sm\:hiddensm:hidden, .h-\[60vh\]h-[60vh])
  • lookup_rules(classes: set[str]) -> str — return concatenated CSS for a set of class names, preserving source order from tw.css
  • get_preamble() -> str — return the preamble (sent once per page load)

The parser uses brace-depth tracking to split minified CSS into individual rules, extracts selectors, unescapes to get HTML class names. Rules inside @media blocks are stored with their wrapping @media.

Step 2: Class Collection During Render — shared/sx/html.py

Add a contextvars.ContextVar[set[str] | None] for collecting classes. In _render_element() (line ~460-469), when processing a class attribute, split the value and add to the collector if active.

# ~3 lines added in _render_element after the attr loop
if attr_name == "class" and attr_val:
    collector = _css_class_collector.get(None)
    if collector is not None:
        collector.update(str(attr_val).split())

Step 3: Sx Source Scanner — shared/sx/css_registry.py

For sx pages where the body is rendered client-side (sx source sent as text, not pre-rendered HTML), we can't use the render-time collector. Instead, scan sx source text for :class "..." patterns:

def scan_classes_from_sx(source: str) -> set[str]:
    """Extract class names from :class "..." in sx source text."""

This runs on both component definitions and page sx source at response time.

Step 4: Wire Into Responses — shared/sx/helpers.py

sx_page() (full page loads, line 416):

  1. Scan component_defs + page_sx for classes via scan_classes_from_sx()
  2. Look up rules + preamble from registry
  3. Replace <script src="https://cdn.tailwindcss.com..."> in _SX_PAGE_TEMPLATE with <style id="sx-css">{preamble}\n{rules}</style>
  4. Add <meta name="sx-css-classes" content="{comma-separated classes}"> so client knows what it has

sx_response() (fragment swaps, line 325):

  1. Read SX-Css header from request (client's known classes)
  2. Scan the sx source for classes
  3. Compute new classes = found - known
  4. If new rules exist, prepend <style data-sx-css>{rules}</style> to response body
  5. Set SX-Css-Add response header with the new class names (so client can track without parsing CSS)

Step 5: Client-Side Tracking — shared/static/scripts/sx.js

Boot (page load):

  • Read <meta name="sx-css-classes"> → populate _sxCssKnown dict

Request header (in _doFetch, line ~1516):

  • After sending SX-Components, also send SX-Css: flex,p-2,sm:hidden,...

Response handling (line ~1641 for text/sx, ~1698 for HTML):

  • Strip <style data-sx-css> blocks from response, inject into <style id="sx-css"> in <head>
  • Read SX-Css-Add response header, merge class names into _sxCssKnown

Step 6: Jinja Path Fallback — shared/browser/templates/_types/root/_head.html

For non-sx pages still using Jinja templates, serve all CSS rules (full registry dump) in <style id="sx-css">. Add a Jinja global sx_css_all() in shared/sx/jinja_bridge.py that returns preamble + all rules. This is the same 92KB but self-hosted with no Tailwind dependency. These pages can be optimized later.

Step 7: Startup — shared/sx/jinja_bridge.py

Call load_css_registry() in setup_sx_bridge() after loading components.

Files to Modify

File Change
shared/sx/css_registry.py NEW — registry, parser, scanner, lookup
shared/sx/html.py Add contextvar + 3 lines in _render_element
shared/sx/helpers.py Modify _SX_PAGE_TEMPLATE, sx_page(), sx_response()
shared/sx/jinja_bridge.py Call load_css_registry() at startup, add sx_css_all() Jinja global
shared/static/scripts/sx.js _sxCssKnown tracking, SX-Css header, response CSS injection
shared/browser/templates/_types/root/_head.html Replace Tailwind CDN with {{ sx_css_all() }}

Key Design Decisions

  • Source of truth: tw.css (already compiled, known to be correct for current classes)
  • Dedup model: Mirrors SX-Components — client declares what it has, server sends the diff
  • Header size: ~599 class names × ~10 chars ≈ 6KB header — within limits, can optimize later with hashing
  • CSS ordering: Registry preserves tw.css source order (later rules win for equal specificity)
  • Preamble: --tw-* custom property defaults (~2KB) always included on first page load

Verification

  1. Start blog service: ./dev.sh blog
  2. Load https://blog.rose-ash.com/index/ — verify styles match current CDN appearance
  3. View page source — should have <style id="sx-css"> instead of Tailwind CDN script
  4. Navigate via sx swap (click a post) — check DevTools Network tab for SX-Css request header and SX-Css-Add response header
  5. Inspect <style id="sx-css"> — should grow as new pages introduce new classes
  6. Check non-sx pages still render correctly (full CSS dump fallback)

Phase 2: S-Expression Styles — Native SX Style Primitives

Context

SX eliminated the HTML/JS divide — code is data is DOM. But one foreign language remains: CSS. Components are full of :class "flex gap-4 items-center p-2 bg-sky-100 rounded" — opaque strings from a separate language (Tailwind) that requires a separate build step (Tailwind v3 CLI), a separate parser (css_registry.py parsing tw.css), and a separate delivery mechanism (hash-based dedup).

Goal: Make styles first-class SX expressions. (css :flex :gap-4 :items-center :p-2 :bg-sky-100 :rounded) replaces "flex gap-4 items-center p-2 bg-sky-100 rounded". Same mental model as Tailwind — atomic utility keywords — but native to the language. No build step. No external CSS framework. Code = data = DOM = styles.

Surface Syntax

;; Before (Tailwind class strings)
(div :class "flex gap-4 items-center p-2 bg-sky-100 rounded" ...)

;; After (SX style expressions)
(div :style (css :flex :gap-4 :items-center :p-2 :bg-sky-100 :rounded) ...)

;; Responsive + pseudo-classes (variant:atom, parsed as single keyword)
(div :style (css :flex :gap-2 :sm:gap-4 :sm:flex-row :hover:bg-sky-200) ...)

;; Named styles
(defstyle card-base (css :rounded-xl :bg-white :shadow :hover:shadow-md :transition))
(div :style card-base ...)

;; Composition
(div :style (merge-styles card-base (css :p-4 :border :border-stone-200)) ...)

;; Conditional
(div :style (if active (css :bg-sky-500 :text-white) (css :bg-stone-100)) ...)

;; Both :class and :style coexist during migration
(div :class "prose" :style (css :p-4 :max-w-3xl) ...)

Why (css :flex :gap-4) not (flex :gap 4) or (style :display :flex :gap "1rem")?

  • Keywords mirror Tailwind class names 1:1 — migration is mechanical search-replace
  • Single css primitive, no namespace pollution (hundreds of functions like flex, p, bg)
  • Parser already handles :hover:bg-sky-200 as one keyword (regex :[a-zA-Z_][a-zA-Z0-9_>:-]*)

Architecture

Three layers

  1. Style Dictionary (style_dict.py) — maps keyword atoms to CSS declarations. Pure data. Replaces tw.css.
  2. Style Resolver (style_resolver.py) — (css :flex :gap-4)StyleValue(class_name="sx-a3f2c1", declarations="display:flex;gap:1rem"). Memoized.
  3. Style Registry — generated CSS rules registered into the existing css_registry.py delivery system. Same hash-based dedup, same <style data-sx-css>, same SX-Css header.

Output: generated classes (not inline styles)

Inline style="..." can't express :hover, :focus, @media breakpoints, or combinators. Generated classes preserve all Tailwind functionality. The css primitive produces a StyleValue with a content-addressed class name. The renderer emits class="sx-a3f2c1" and registers the CSS rule for on-demand delivery.

@ Rules (Animations, Keyframes, Containers)

@media breakpoints are handled via responsive variants (:sm:flex-row), but CSS has other @ rules that need first-class support:

@keyframes — via defkeyframes

;; Define a keyframes animation
(defkeyframes fade-in
  (from (css :opacity-0))
  (to (css :opacity-100)))

(defkeyframes slide-up
  ("0%" (css :translate-y-4 :opacity-0))
  ("100%" (css :translate-y-0 :opacity-100)))

;; Use it — animate-[name] atom references the keyframes
(div :style (css :animate-fade-in :duration-300) ...)

Implementation: defkeyframes is a special form that:

  1. Evaluates each step's (css ...) body to get declarations
  2. Builds a @keyframes fade-in { from { opacity:0 } to { opacity:1 } } rule
  3. Registers the @keyframes rule in css_registry.py via register_generated_rule()
  4. Binds the name so animate-fade-in can reference it

Built-in animations in style_dict.py:

# Keyframes registered at dictionary load time
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)}}",
}

# Animation atoms reference keyframes by name
STYLE_ATOMS |= {
    "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",
    "duration-75": "animation-duration:75ms",
    "duration-100": "animation-duration:100ms",
    "duration-150": "animation-duration:150ms",
    "duration-200": "animation-duration:200ms",
    "duration-300": "animation-duration:300ms",
    "duration-500": "animation-duration:500ms",
    "duration-700": "animation-duration:700ms",
    "duration-1000": "animation-duration:1000ms",
}

When the resolver encounters animate-spin, it emits both the class rule AND ensures the @keyframes spin rule is registered. The @keyframes rules flow through the same _REGISTRYlookup_rules()SX-Css delta pipeline.

@container queries

;; Container context
(div :style (css :container :container-name-sidebar) ...)

;; Container query variant (like responsive but scoped to container)
(div :style (css :flex-col :@sm/sidebar:flex-row) ...)

Variant prefix @sm/sidebar@container sidebar (min-width: 640px). Parsed the same way as responsive variants but emits @container instead of @media.

@font-face

Not needed as atoms — font loading stays in basics.css or a dedicated (load-font ...) primitive. Fonts are infrastructure, not component styles.

Dynamic Class Generation

Static atoms (common case)

(css :flex :gap-4 :bg-sky-100)

All atoms are keywords known at parse time. Server and client both resolve from the dictionary. No issues.

Dynamic atoms (runtime-computed)

;; Color from data
(let ((color (get item "color")))
  (div :style (css :p-4 :rounded (str "bg-" color "-100")) ...))

;; Numeric from computation
(div :style (css :flex (str "gap-" (if compact "1" "4"))) ...)

The css primitive accepts both keywords and strings. When it receives a string like "bg-sky-100", it looks it up in STYLE_ATOMS the same way. This works on both server and client because both have the full dictionary in memory.

No server round-trip needed — the client has the complete style dictionary cached in localStorage. Dynamic atom lookup is a local hash table read, same as static atoms.

Arbitrary values (escape hatch)

For values not in the dictionary — truly custom measurements, colors, etc.:

;; Arbitrary value syntax (mirrors Tailwind's bracket notation)
(css :w-[347px] :h-[calc(100vh-4rem)] :bg-[#ff6b35])

Pattern-based generator in the resolver (both server and client):

ARBITRARY_PATTERNS: list[tuple[re.Pattern, Callable]] = [
    # w-[value] → width:value
    (re.compile(r"w-\[(.+)\]"), lambda v: f"width:{v}"),
    # h-[value] → height:value
    (re.compile(r"h-\[(.+)\]"), lambda v: f"height:{v}"),
    # bg-\[value] → background-color:value
    (re.compile(r"bg-\[(.+)\]"), lambda v: f"background-color:{v}"),
    # p-[value] → padding:value
    (re.compile(r"p-\[(.+)\]"), lambda v: f"padding:{v}"),
    # text-[value] → font-size:value
    (re.compile(r"text-\[(.+)\]"), lambda v: f"font-size:{v}"),
    # top/right/bottom/left-[value]
    (re.compile(r"(top|right|bottom|left)-\[(.+)\]"), lambda d, v: f"{d}:{v}"),
    # grid-cols-[value] → grid-template-columns:value
    (re.compile(r"grid-cols-\[(.+)\]"), lambda v: f"grid-template-columns:{v}"),
    # min/max-w/h-[value]
    (re.compile(r"(min|max)-(w|h)-\[(.+)\]"),
     lambda mm, dim, v: f"{'width' if dim=='w' else 'height'}:{v}" if mm=='max' else f"min-{'width' if dim=='w' else 'height'}:{v}"),
]

Resolution order: dictionary lookup → pattern match → error (unknown atom).

The generator runs client-side too (it's just regex + string formatting), so arbitrary values never cause a server round-trip. The generated class and CSS rule are injected into <style id="sx-css"> on the client, same as dictionary-resolved atoms.

Fully dynamic (data-driven colors/sizes)

For cases where the CSS property and value are both runtime data (e.g., user-chosen brand colors stored in the database):

;; Inline style fallback — when value is truly unknown
(div :style (str "background-color:" brand-color) ...)

;; Or a raw-css escape hatch
(div :style (raw-css "background-color" brand-color) ...)

These emit inline style="..." attributes, bypassing the class generation system. This is correct — these values are unique per-entity, so generating a class would be wasteful (class never reused). Inline styles are the right tool for truly unique values.

Style Delivery & Caching

Current system (CSS classes)

  1. Full page load: Server scans rendered SX for class names → lookup_rules() gets CSS for those classes → embeds in <style id="sx-css"> + stores hash in <meta name="sx-css-classes">
  2. Subsequent SX requests: Client sends SX-Css: {8-char-hash} header → server resolves hash to known class set → computes delta (new classes only) → sends <style data-sx-css>{new rules}</style> inline in response + SX-Css-Hash response header with updated cumulative hash
  3. Client accumulates: sx.js extracts <style data-sx-css> blocks, appends rules to <style id="sx-css">, updates its _sxCssHash

Current system (components)

  • Components cached in localStorage by content hash
  • Server checks sx-comp-hash cookie → if client has current hash, omits component source from response body
  • Client loads from localStorage on cache hit, downloads on miss

New system (SX styles) — same pattern as components

Key insight: The style dictionary (STYLE_ATOMS) is a fixed dataset, like component definitions. It changes only on deployment, not per-request. Cache it in localStorage like components, not per-request like CSS class deltas.

Server side:

  • At startup, hash the full style dictionary → sx-style-dict-hash
  • Check sx-style-hash cookie on each request
  • If client has current hash: omit dictionary from response
  • If client is stale/missing: include <script type="text/sx-styles" data-hash="{hash}">{serialized dict}</script> in full-page response
  • Generated CSS rules (from (css ...) evaluation) are tracked the same way current CSS classes are — server sends only new rules client doesn't have

Client side (sx.js):

  • On full page load: check <script type="text/sx-styles" data-hash="{hash}">
  • If hash matches localStorage sx-styles-hash: load from localStorage (skip download)
  • If hash differs or no cache: parse inline dict, store in localStorage, set cookie
  • Style dictionary lives in memory as a JS object for css primitive lookups
  • Generated CSS rules injected into <style id="sx-css"> (same as current system)

Per-request style delivery (for SX responses after initial page):

  • (css ...) produces StyleValue on server → renderer emits class="sx-a3f2c1"
  • Server registers generated rule in _REGISTRYlookup_rules() picks it up
  • Existing SX-Css hash mechanism sends only new CSS rules to client
  • No change needed to the delta delivery pipeline — generated class names flow through lookup_rules() exactly like Tailwind class names do today

Server-side session tracking (optimization):

  • Server maintains dict[client_id, set[str]] mapping client IDs to known style rule hashes
  • Client ID = session cookie or device ID (already exists in rose-ash auth system)
  • On each response, server records which style rules were sent to this client
  • On subsequent requests, server checks its record before computing delta
  • Falls back to hash-based negotiation if server-side record is missing (restart, eviction)
  • This avoids the round-trip cost of the client needing to tell the server what it knows — the server already knows

Data transfer optimization:

  • Style dictionary: ~15-20KB serialized, sent once, cached in localStorage indefinitely (until hash changes on deploy)
  • Per-request: only delta CSS rules (typically 0-500 bytes for navigation to a new page type)
  • Preamble (resets, FontAwesome, basics.css): sent once on full page load, same as today
  • Total initial download actually decreases: style dict (~20KB) < tw.css sent as rules (~40KB+ for pages using many classes)

Implementation Phases

Phase 2.0: Style Dictionary

New file: shared/sx/style_dict.py

Pure data mapping ~500 keyword atoms (the ones actually used across the codebase) to CSS declarations:

STYLE_ATOMS: dict[str, str] = {
    "flex": "display:flex",
    "hidden": "display:none",
    "block": "display:block",
    "flex-col": "flex-direction:column",
    "flex-row": "flex-direction:row",
    "items-center": "align-items:center",
    "justify-between": "justify-content:space-between",
    "gap-1": "gap:0.25rem",
    "gap-2": "gap:0.5rem",
    "gap-4": "gap:1rem",
    "p-2": "padding:0.5rem",
    "px-4": "padding-left:1rem;padding-right:1rem",
    "bg-sky-100": "background-color:rgb(224 242 254)",
    "rounded": "border-radius:0.25rem",
    "rounded-xl": "border-radius:0.75rem",
    "text-sm": "font-size:0.875rem;line-height:1.25rem",
    "font-semibold": "font-weight:600",
    "shadow": "box-shadow:0 1px 3px 0 rgb(0 0 0/0.1),0 1px 2px -1px rgb(0 0 0/0.1)",
    "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(0.4,0,0.2,1);transition-duration:150ms",
    # ... ~500 entries total
}

PSEUDO_VARIANTS: dict[str, str] = {
    "hover": ":hover", "focus": ":focus", "active": ":active",
    "disabled": ":disabled", "first": ":first-child", "last": ":last-child",
    "group-hover": ":is(.group:hover) &",
}

RESPONSIVE_BREAKPOINTS: dict[str, str] = {
    "sm": "(min-width:640px)", "md": "(min-width:768px)",
    "lg": "(min-width:1024px)", "xl": "(min-width:1280px)",
}

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
ARBITRARY_PATTERNS: list[tuple[str, str]] = [
    # pattern → CSS template ({0} = captured value)
    (r"w-\[(.+)\]", "width:{0}"),
    (r"h-\[(.+)\]", "height:{0}"),
    (r"bg-\[(.+)\]", "background-color:{0}"),
    (r"p-\[(.+)\]", "padding:{0}"),
    (r"m-\[(.+)\]", "margin:{0}"),
    (r"text-\[(.+)\]", "font-size:{0}"),
    (r"(top|right|bottom|left)-\[(.+)\]", "{0}:{1}"),
    (r"(min|max)-(w|h)-\[(.+)\]", "{0}-{1}:{2}"),
    (r"grid-cols-\[(.+)\]", "grid-template-columns:{0}"),
    (r"gap-\[(.+)\]", "gap:{0}"),
]

Generated by: scanning all :class "..." across 64 .sx files to find used atoms, then extracting their CSS from the existing tw.css via css_registry.py's parsed _REGISTRY.

Phase 2.1: StyleValue type + css primitive + resolver

Modify: shared/sx/types.py — add StyleValue:

@dataclass(frozen=True)
class StyleValue:
    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{...}"), ...)
    container_rules: tuple = ()  # (("sidebar (min-width:640px)", decls), ...)

New file: shared/sx/style_resolver.py — memoized resolver:

  • Takes tuple of atom strings (e.g., ("flex", "gap-4", "hover:bg-sky-200", "sm:flex-row"))
  • Splits variant prefixes (hover:bg-sky-200 → variant=hover, atom=bg-sky-200)
  • Looks up declarations in STYLE_ATOMS
  • Falls back to ARBITRARY_PATTERNS for bracket notation (w-[347px]width:347px)
  • Detects animate-* atoms → includes associated @keyframes rules
  • Groups into base / pseudo / media / keyframes / container
  • Hashes declarations → deterministic class name sx-{hash[:6]}
  • Returns StyleValue
  • Dict cache keyed on input tuple
  • Accepts both keywords and runtime strings (for dynamic atom construction)

Modify: shared/sx/primitives.py — add css and merge-styles:

@register_primitive("css")
def prim_css(*args):
    from .style_resolver import resolve_style
    return resolve_style(tuple(str(a) for a in args if a))

@register_primitive("merge-styles")
def prim_merge_styles(*styles):
    from .style_resolver import merge_styles
    return merge_styles([s for s in styles if isinstance(s, StyleValue)])

Phase 2.2: Server-side rendering + delivery integration

Modify: shared/sx/html.py — in _render_element() (line ~482):

  • When :style evaluates to a StyleValue: emit its class_name as a CSS class (appended to any existing :class), register the rule with register_generated_rule(), don't emit :style attribute
  • When :style is a string: existing behavior (inline style attribute)

Modify: shared/sx/async_eval.py — same change in _arender_element() (line ~641)

Modify: shared/sx/css_registry.py — add register_generated_rule(style_val):

  • Builds CSS rule: .sx-a3f2c1{display:flex;gap:1rem}
  • Plus pseudo rules: .sx-a3f2c1:hover{background-color:...}
  • Plus media rules: @media(min-width:640px){.sx-a3f2c1{flex-direction:row}}
  • Inserts into _REGISTRY so existing lookup_rules() works transparently
  • Generated rules flow through the same SX-Css hash delta mechanism — no new delivery protocol needed

Modify: shared/sx/helpers.py — style dictionary delivery:

  • In sx_page_shell() (full page): include style dictionary as <script type="text/sx-styles" data-hash="{hash}"> with localStorage caching (same pattern as component caching)
  • Check sx-style-hash cookie: if client has current hash, omit dictionary source
  • In sx_response() (SX fragment responses): no change — generated CSS rules already flow through <style data-sx-css>

Modify: shared/infrastructure/factory.py — add sx-style-hash to allowed headers in CORS config

Phase 2.3: Client-side (sx.js)

Modify: shared/static/scripts/sx.js:

  • Add StyleValue type ({_style: true, className, declarations, pseudoRules, mediaRules})
  • Add css primitive to PRIMITIVES (accepts both keywords and dynamic strings)
  • Add resolver logic (split variants, lookup from in-memory dict, arbitrary pattern fallback, hash, memoize)
  • In renderElement(): when :style value is StyleValue, add className to element and inject CSS rule into <style id="sx-css"> (same target as server-sent rules)
  • Add merge-styles primitive
  • Add defstyle to SPECIAL_FORMS
  • Add style dictionary localStorage caching (same pattern as components):
    • On init: check <script type="text/sx-styles" data-hash="{hash}">
    • Cache hit (hash matches localStorage): load dict from localStorage, skip inline parse
    • Cache miss: parse inline dict, store in localStorage, set sx-style-hash cookie
    • Dict lives in _styleAtoms var for css primitive to look up at render time

No separate sx-styles.js — the style dictionary is delivered inline in the full-page shell (like components) and cached in localStorage. No extra HTTP request.

Phase 2.4: defstyle and defkeyframes special forms

Modify: shared/sx/evaluator.py — add defstyle and defkeyframes:

(defstyle card-base (css :rounded-xl :bg-white :shadow))

(defkeyframes fade-in
  (from (css :opacity-0))
  (to (css :opacity-100)))

defstyle: evaluates the body → StyleValue, binds to name in env. Essentially define but semantically distinct for tooling.

defkeyframes: evaluates each step's (css ...) body, builds a @keyframes CSS rule, registers it via register_generated_rule(), and binds the animation name so animate-[name] atoms can reference it.

Mirror in shared/sx/async_eval.py and sx.js.

Phase 2.5: Migration tooling + gradual conversion

New: shared/sx/tools/class_to_css.py — converter script:

  • :class "flex gap-4 p-2":style (css :flex :gap-4 :p-2)
  • (str "base " conditional) → leave as :class or split into static :style + dynamic :class
  • (if cond "classes-a" "classes-b")(if cond (css :classes-a) (css :classes-b))

Dynamic class construction (2-3 occurrences in layout.sx):

  • (str "bg-" c "-" shade)(css (str "bg-" c "-" shade))css accepts runtime strings, resolves from dictionary client-side (no server round-trip)
  • Truly unique values (user brand colors from DB) → inline style="..." or (raw-css "background-color" brand-color)

Phase 2.6: Remove Tailwind

  • Delete tailwind.config.js, remove tw.css build step
  • Remove tw.css parsing from load_css_registry()
  • Keep extra CSS (basics.css, cards.css, blog-content.css, FontAwesome)
  • css_registry.py becomes pure runtime registry for generated + extra CSS

Phase 2 Files

File Change
shared/sx/style_dict.py New — keyword → CSS declaration mapping (~500 atoms)
shared/sx/style_resolver.py New — resolve (css ...) → StyleValue, memoized
shared/sx/types.py Add StyleValue dataclass
shared/sx/primitives.py Add css, merge-styles primitives
shared/sx/html.py Handle StyleValue in :style attribute rendering
shared/sx/async_eval.py Same StyleValue handling in async render path
shared/sx/css_registry.py Add register_generated_rule()
shared/sx/helpers.py Style dict delivery in page shell, cookie check, localStorage caching protocol
shared/sx/evaluator.py Add defstyle special form
shared/infrastructure/factory.py Add sx-style-hash cookie/header to CORS
shared/static/scripts/sx.js StyleValue, css/merge-styles, defstyle, dict caching, style injection
shared/sx/tools/class_to_css.py New — migration converter

Phase 2 Verification

  • Phase 2.1: Unit test — (css :flex :gap-4 :p-2) returns correct StyleValue
  • Phase 2.2: Render test — (div :style (css :flex :gap-4))<div class="sx-a3f2c1"> + CSS rule registered
  • Phase 2.3: Browser test — client renders :style (css ...) with injected <style> rules
  • Phase 2.5: Convert one .sx file, diff HTML output to verify identical rendering
  • Throughout: existing :class "..." continues to work unchanged