Files
rose-ash/docs/cssx.md
giles c0d369eb8e
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m0s
Refactor SX templates: shared components, Python migration, cleanup
- Extract shared components (empty-state, delete-btn, sentinel, crud-*,
  view-toggle, img-or-placeholder, avatar, sumup-settings-form, auth
  forms, order tables/detail/checkout)
- Migrate all Python sx_call() callers to use shared components directly
- Remove 55+ thin wrapper defcomps from domain .sx files
- Remove trivial passthrough wrappers (blog-header-label, market-card-text, etc)
- Unify duplicate auth flows (account + federation) into shared/sx/templates/auth.sx
- Unify duplicate order views (cart + orders) into shared/sx/templates/orders.sx
- Disable static file caching in dev (SEND_FILE_MAX_AGE_DEFAULT=0)
- Add SX response validation and debug headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:34:34 +00:00

6.3 KiB
Raw Permalink 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 (Future)

  • Component-level pre-computation: Pre-scan classes per component at registration time
  • Own rule generator: Replace tw.css parsing with a Python rule engine (no Tailwind dependency at all)
  • Header compression: Use bitfield or hash instead of full class list
  • Critical CSS: Only inline above-fold CSS, lazy-load rest