# 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\:hidden` → `sm: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.

```python
# ~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:

```python
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

```lisp
;; 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`

```lisp
;; 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`:
```python
# 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 `_REGISTRY` → `lookup_rules()` → `SX-Css` delta pipeline.

#### `@container` queries

```lisp
;; 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)

```lisp
(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)

```lisp
;; 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.:

```lisp
;; 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):
```python
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):

```lisp
;; 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 `_REGISTRY` → `lookup_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:

```python
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:
```python
@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`:
```python
@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`:
```lisp
(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
