Add @ rules, dynamic generation, and arbitrary values to SX styles plan

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>
This commit is contained in:
2026-03-04 10:05:25 +00:00
parent 96132d9cfe
commit 03f9968979

View File

@@ -156,6 +156,153 @@ SX eliminated the HTML/JS divide — code is data is DOM. But one foreign langua
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. 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`
```lisp
;; 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:
1. Evaluates each step's `(css ...)` body to get declarations
2. Builds a `@keyframes fade-in { from { opacity:0 } to { opacity:1 } }` rule
3. Registers the `@keyframes` rule in `css_registry.py` via `register_generated_rule()`
4. Binds the name so `animate-fade-in` can reference it
**Built-in animations** in `style_dict.py`:
```python
# 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
```lisp
;; 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)
```lisp
(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)
```lisp
;; 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.:
```lisp
;; 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):
```python
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):
```lisp
;; 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 ### Style Delivery & Caching
#### Current system (CSS classes) #### Current system (CSS classes)
@@ -250,6 +397,28 @@ RESPONSIVE_BREAKPOINTS: dict[str, str] = {
"sm": "(min-width:640px)", "md": "(min-width:768px)", "sm": "(min-width:640px)", "md": "(min-width:768px)",
"lg": "(min-width:1024px)", "xl": "(min-width:1280px)", "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`. 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`.
@@ -264,16 +433,21 @@ class StyleValue:
declarations: str # "display:flex;gap:1rem" declarations: str # "display:flex;gap:1rem"
media_rules: tuple = () # ((query, decls), ...) media_rules: tuple = () # ((query, decls), ...)
pseudo_rules: tuple = () # ((selector, 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: **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")`) - 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`) - Splits variant prefixes (`hover:bg-sky-200` → variant=`hover`, atom=`bg-sky-200`)
- Looks up declarations in STYLE_ATOMS - Looks up declarations in STYLE_ATOMS
- Groups into base / pseudo / media - Falls back to `ARBITRARY_PATTERNS` for bracket notation (`w-[347px]``width:347px`)
- Detects `animate-*` atoms → includes associated `@keyframes` rules
- Groups into base / pseudo / media / keyframes / container
- Hashes declarations → deterministic class name `sx-{hash[:6]}` - Hashes declarations → deterministic class name `sx-{hash[:6]}`
- Returns `StyleValue` - Returns `StyleValue`
- Dict cache keyed on input tuple - 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`: **Modify: `shared/sx/primitives.py`** — add `css` and `merge-styles`:
```python ```python
@@ -314,8 +488,8 @@ def prim_merge_styles(*styles):
**Modify: `shared/static/scripts/sx.js`**: **Modify: `shared/static/scripts/sx.js`**:
- Add `StyleValue` type (`{_style: true, className, declarations, pseudoRules, mediaRules}`) - Add `StyleValue` type (`{_style: true, className, declarations, pseudoRules, mediaRules}`)
- Add `css` primitive to PRIMITIVES - Add `css` primitive to PRIMITIVES (accepts both keywords and dynamic strings)
- Add resolver logic (split variants, lookup from in-memory dict, hash, memoize) - Add resolver logic (split variants, lookup from in-memory dict, arbitrary pattern fallback, 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) - 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 `merge-styles` primitive
- Add `defstyle` to SPECIAL_FORMS - Add `defstyle` to SPECIAL_FORMS
@@ -327,13 +501,20 @@ def prim_merge_styles(*styles):
**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. **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 #### Phase 2.4: `defstyle` and `defkeyframes` special forms
**Modify: `shared/sx/evaluator.py`** — add `defstyle`: **Modify: `shared/sx/evaluator.py`** — add `defstyle` and `defkeyframes`:
```lisp ```lisp
(defstyle card-base (css :rounded-xl :bg-white :shadow)) (defstyle card-base (css :rounded-xl :bg-white :shadow))
(defkeyframes fade-in
(from (css :opacity-0))
(to (css :opacity-100)))
``` ```
Evaluates the body → StyleValue, binds to name in env. Essentially `define` but semantically distinct for tooling.
`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`. **Mirror in `shared/sx/async_eval.py`** and `sx.js`.
@@ -345,7 +526,8 @@ Evaluates the body → StyleValue, binds to name in env. Essentially `define` bu
- `(if cond "classes-a" "classes-b")``(if cond (css :classes-a) (css :classes-b))` - `(if cond "classes-a" "classes-b")``(if cond (css :classes-a) (css :classes-b))`
**Dynamic class construction** (2-3 occurrences in `layout.sx`): **Dynamic class construction** (2-3 occurrences in `layout.sx`):
- `(str "bg-" c "-" shade)` — keep as `:class` or add `(css-dynamic ...)` runtime lookup - `(str "bg-" c "-" shade)` `(css (str "bg-" c "-" shade))` `css` accepts 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 #### Phase 2.6: Remove Tailwind