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:
196
docs/cssx.md
196
docs/cssx.md
@@ -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.
|
||||
|
||||
### @ 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
|
||||
|
||||
#### Current system (CSS classes)
|
||||
@@ -250,6 +397,28 @@ RESPONSIVE_BREAKPOINTS: dict[str, str] = {
|
||||
"sm": "(min-width:640px)", "md": "(min-width:768px)",
|
||||
"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`.
|
||||
@@ -264,16 +433,21 @@ class StyleValue:
|
||||
declarations: str # "display:flex;gap:1rem"
|
||||
media_rules: tuple = () # ((query, 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:
|
||||
- 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
|
||||
- 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]}`
|
||||
- Returns `StyleValue`
|
||||
- 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`:
|
||||
```python
|
||||
@@ -314,8 +488,8 @@ def prim_merge_styles(*styles):
|
||||
|
||||
**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)
|
||||
- Add `css` primitive to PRIMITIVES (accepts both keywords and dynamic strings)
|
||||
- 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)
|
||||
- Add `merge-styles` primitive
|
||||
- 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.
|
||||
|
||||
#### 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
|
||||
(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`.
|
||||
|
||||
@@ -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))`
|
||||
|
||||
**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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user