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>
This commit is contained in:
277
docs/cssx.md
277
docs/cssx.md
@@ -105,9 +105,276 @@ Call `load_css_registry()` in `setup_sx_bridge()` after loading components.
|
||||
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)
|
||||
## Phase 2: S-Expression Styles — Native SX Style Primitives
|
||||
|
||||
- **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
|
||||
### 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.
|
||||
|
||||
### 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)",
|
||||
}
|
||||
```
|
||||
|
||||
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), ...)
|
||||
```
|
||||
|
||||
**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`:
|
||||
```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
|
||||
- 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`:
|
||||
```lisp
|
||||
(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
|
||||
|
||||
Reference in New Issue
Block a user