From 03f996897971b9a717c9ba5e7a6f959d8e4d4e1f Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 10:05:25 +0000 Subject: [PATCH] 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 --- docs/cssx.md | 196 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 189 insertions(+), 7 deletions(-) diff --git a/docs/cssx.md b/docs/cssx.md index 2423854..0c1ac98 100644 --- a/docs/cssx.md +++ b/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 `