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>
29 KiB
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/@layerdeclarations 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.cssget_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):
- Scan
component_defs+page_sxfor classes viascan_classes_from_sx() - Look up rules + preamble from registry
- Replace
<script src="https://cdn.tailwindcss.com...">in_SX_PAGE_TEMPLATEwith<style id="sx-css">{preamble}\n{rules}</style> - Add
<meta name="sx-css-classes" content="{comma-separated classes}">so client knows what it has
sx_response() (fragment swaps, line 325):
- Read
SX-Cssheader from request (client's known classes) - Scan the sx source for classes
- Compute new classes = found - known
- If new rules exist, prepend
<style data-sx-css>{rules}</style>to response body - Set
SX-Css-Addresponse 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_sxCssKnowndict
Request header (in _doFetch, line ~1516):
- After sending
SX-Components, also sendSX-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-Addresponse 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
- Start blog service:
./dev.sh blog - Load
https://blog.rose-ash.com/index/— verify styles match current CDN appearance - View page source — should have
<style id="sx-css">instead of Tailwind CDN script - Navigate via sx swap (click a post) — check DevTools Network tab for
SX-Cssrequest header andSX-Css-Addresponse header - Inspect
<style id="sx-css">— should grow as new pages introduce new classes - 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
cssprimitive, no namespace pollution (hundreds of functions likeflex,p,bg) - Parser already handles
:hover:bg-sky-200as one keyword (regex:[a-zA-Z_][a-zA-Z0-9_>:-]*)
Architecture
Three layers
- Style Dictionary (
style_dict.py) — maps keyword atoms to CSS declarations. Pure data. Replaces tw.css. - Style Resolver (
style_resolver.py) —(css :flex :gap-4)→StyleValue(class_name="sx-a3f2c1", declarations="display:flex;gap:1rem"). Memoized. - Style Registry — generated CSS rules registered into the existing
css_registry.pydelivery system. Same hash-based dedup, same<style data-sx-css>, sameSX-Cssheader.
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:
- Evaluates each step's
(css ...)body to get declarations - Builds a
@keyframes fade-in { from { opacity:0 } to { opacity:1 } }rule - Registers the
@keyframesrule incss_registry.pyviaregister_generated_rule() - Binds the name so
animate-fade-incan 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 _REGISTRY → lookup_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)
- 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"> - 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-Hashresponse header with updated cumulative hash - Client accumulates:
sx.jsextracts<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-hashcookie → 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-hashcookie 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
cssprimitive 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 ...)producesStyleValueon server → renderer emitsclass="sx-a3f2c1"- Server registers generated rule in
_REGISTRY→lookup_rules()picks it up - Existing
SX-Csshash 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_PATTERNSfor bracket notation (w-[347px]→width:347px) - Detects
animate-*atoms → includes associated@keyframesrules - 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
:styleevaluates to aStyleValue: emit itsclass_nameas a CSS class (appended to any existing:class), register the rule withregister_generated_rule(), don't emit:styleattribute - When
:styleis 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
_REGISTRYso existinglookup_rules()works transparently - Generated rules flow through the same
SX-Csshash 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-hashcookie: 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
StyleValuetype ({_style: true, className, declarations, pseudoRules, mediaRules}) - Add
cssprimitive 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:stylevalue is StyleValue, add className to element and inject CSS rule into<style id="sx-css">(same target as server-sent rules) - Add
merge-stylesprimitive - Add
defstyleto 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-hashcookie - Dict lives in
_styleAtomsvar forcssprimitive to look up at render time
- On init: check
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:classor 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))—cssaccepts 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.pybecomes 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