Files
rose-ash/docs/cssx.md
giles 96132d9cfe
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m10s
Add Phase 2 SX styles plan to cssx.md
Document the design for native s-expression style primitives
(css :flex :gap-4 ...) to replace Tailwind CSS strings with first-class
SX expressions. Covers style dictionary, resolver, delivery/caching
(localStorage like components), server-side session tracking, and
migration tooling.

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

20 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.

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

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

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
  • Groups into base / pseudo / media
  • Hashes declarations → deterministic class name sx-{hash[:6]}
  • Returns StyleValue
  • Dict cache keyed on input tuple

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
  • Add resolver logic (split variants, lookup from in-memory dict, 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 special form

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

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

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

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) — keep as :class or add (css-dynamic ...) runtime lookup

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