- 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>
114 lines
6.3 KiB
Markdown
114 lines
6.3 KiB
Markdown
# 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 (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
|