Reactive island preservation across server-driven morphs

Islands survive hypermedia swaps: morph-node skips hydrated
data-sx-island elements when the same island exists in new content.
dispose-islands-in skips hydrated islands to prevent premature cleanup.

- @client directive: .sx files marked ;; @client send define forms to browser
- CSSX client-side: cssxgroup renamed (no hyphen) to avoid isRenderExpr
  matching it as a custom element — was producing [object HTMLElement]
- Island wrappers: div→span to avoid block-in-inline HTML parse breakage
- ~sx-header is now a defisland with inline reactive colour cycling
- bootstrap_js.py defaults output to shared/static/scripts/sx-browser.js
- Deleted stale sx-ref.js (sx-browser.js is the canonical browser build)
- Hegelian Synthesis essay: dialectic of hypertext and reactivity
- component-source helper handles Island types for docs pretty-printing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 14:10:35 +00:00
parent 8a5c115557
commit d5e416e478
15 changed files with 458 additions and 5440 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-10T10:47:20Z";
var SX_VERSION = "2026-03-10T14:05:08Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -629,7 +629,7 @@
var callLambda = function(f, args, callerEnv) { return (function() {
var params = lambdaParams(f);
var local = envMerge(lambdaClosure(f), callerEnv);
return (isSxTruthy((len(args) != len(params))) ? error((String(sxOr(lambdaName(f), "lambda")) + String(" expects ") + String(len(params)) + String(" args, got ") + String(len(args)))) : (forEach(function(pair) { return envSet(local, first(pair), nth(pair, 1)); }, zip(params, args)), makeThunk(lambdaBody(f), local)));
return (isSxTruthy((len(args) > len(params))) ? error((String(sxOr(lambdaName(f), "lambda")) + String(" expects ") + String(len(params)) + String(" args, got ") + String(len(args)))) : (forEach(function(pair) { return envSet(local, first(pair), nth(pair, 1)); }, zip(params, args)), forEach(function(p) { return envSet(local, p, NIL); }, slice(params, len(args))), makeThunk(lambdaBody(f), local)));
})(); };
// call-component
@@ -1336,7 +1336,7 @@ continue; } else { return NIL; } } };
return (function() {
var bodyHtml = renderToHtml(componentBody(island), local);
var stateJson = serializeIslandState(kwargs);
return (String("<div data-sx-island=\"") + String(escapeAttr(islandName)) + String("\"") + String((isSxTruthy(stateJson) ? (String(" data-sx-state=\"") + String(escapeAttr(stateJson)) + String("\"")) : "")) + String(">") + String(bodyHtml) + String("</div>"));
return (String("<span data-sx-island=\"") + String(escapeAttr(islandName)) + String("\"") + String((isSxTruthy(stateJson) ? (String(" data-sx-state=\"") + String(escapeAttr(stateJson)) + String("\"")) : "")) + String(">") + String(bodyHtml) + String("</span>"));
})();
})();
})(); };
@@ -1761,7 +1761,7 @@ return result; }, args);
})();
}
return (function() {
var container = domCreateElement("div", NIL);
var container = domCreateElement("span", NIL);
var disposers = [];
domSetAttr(container, "data-sx-island", islandName);
return (function() {
@@ -2053,7 +2053,7 @@ return (function() {
})(); };
// morph-node
var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy(sxOr(!isSxTruthy((domNodeType(oldNode) == domNodeType(newNode))), !isSxTruthy((domNodeName(oldNode) == domNodeName(newNode))))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!isSxTruthy((domTextContent(oldNode) == domTextContent(newNode)))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!isSxTruthy((isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode)))) ? morphChildren(oldNode, newNode) : NIL)) : NIL)))); };
var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy((isSxTruthy(domHasAttr(oldNode, "data-sx-island")) && isSxTruthy(isProcessed(oldNode, "island-hydrated")) && isSxTruthy(domHasAttr(newNode, "data-sx-island")) && (domGetAttr(oldNode, "data-sx-island") == domGetAttr(newNode, "data-sx-island")))) ? NIL : (isSxTruthy(sxOr(!isSxTruthy((domNodeType(oldNode) == domNodeType(newNode))), !isSxTruthy((domNodeName(oldNode) == domNodeName(newNode))))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!isSxTruthy((domTextContent(oldNode) == domTextContent(newNode)))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!isSxTruthy((isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode)))) ? morphChildren(oldNode, newNode) : NIL)) : NIL))))); };
// sync-attrs
var syncAttrs = function(oldEl, newEl) { { var _c = domAttrList(newEl); for (var _i = 0; _i < _c.length; _i++) { var attr = _c[_i]; (function() {
@@ -2982,7 +2982,10 @@ callExpr.push(dictGet(kwargs, k)); } }
// dispose-islands-in
var disposeIslandsIn = function(root) { return (isSxTruthy(root) ? (function() {
var islands = domQueryAll(root, "[data-sx-island]");
return (isSxTruthy((isSxTruthy(islands) && !isSxTruthy(isEmpty(islands)))) ? (logInfo((String("disposing ") + String(len(islands)) + String(" island(s)"))), forEach(disposeIsland, islands)) : NIL);
return (isSxTruthy((isSxTruthy(islands) && !isSxTruthy(isEmpty(islands)))) ? (function() {
var toDispose = filter(function(el) { return !isSxTruthy(isProcessed(el, "island-hydrated")); }, islands);
return (isSxTruthy(!isSxTruthy(isEmpty(toDispose))) ? (logInfo((String("disposing ") + String(len(toDispose)) + String(" island(s)"))), forEach(disposeIsland, toDispose)) : NIL);
})() : NIL);
})() : NIL); };
// boot-init
@@ -3396,8 +3399,9 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
if (!_hasDom || !el) return function() {};
// Wrap SX lambdas from runtime-evaluated island code into native fns
var wrapped = isLambda(handler)
? function(e) { invoke(handler, e); }
? function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
: handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
el.addEventListener(name, wrapped);
return function() { el.removeEventListener(name, wrapped); };
}

View File

@@ -414,7 +414,7 @@ def _render_component(comp: Component, args: list, env: dict[str, Any]) -> str:
def _render_island(island: Island, args: list, env: dict[str, Any]) -> str:
"""Render an island as static HTML with hydration attributes.
Produces: <div data-sx-island="name" data-sx-state='{"k":"v",...}'>body HTML</div>
Produces: <span data-sx-island="name" data-sx-state='{"k":"v",...}'>body HTML</span>
The client hydrates this into a reactive island.
"""
import json as _json
@@ -460,12 +460,12 @@ def _render_island(island: Island, args: list, env: dict[str, Any]) -> str:
state_json = _escape_attr(_json.dumps(state, separators=(",", ":"))) if state else ""
island_name = _escape_attr(island.name)
parts = [f'<div data-sx-island="{island_name}"']
parts = [f'<span data-sx-island="{island_name}"']
if state_json:
parts.append(f' data-sx-state="{state_json}"')
parts.append(">")
parts.append(body_html)
parts.append("</div>")
parts.append("</span>")
return "".join(parts)

View File

@@ -46,6 +46,11 @@ _COMPONENT_ENV: dict[str, Any] = {}
# client-side localStorage caching.
_COMPONENT_HASH: str = ""
# Raw source of .sx files marked with ;; @client — sent to the browser
# alongside component definitions so define forms (functions, data) are
# available for client-side evaluation (e.g. cssx colour/spacing functions).
_CLIENT_LIBRARY_SOURCES: list[str] = []
def get_component_env() -> dict[str, Any]:
"""Return the shared component environment."""
@@ -61,7 +66,7 @@ def _compute_component_hash() -> None:
"""Recompute _COMPONENT_HASH from all registered Component and Macro definitions."""
global _COMPONENT_HASH
from .parser import serialize
parts = []
parts = list(_CLIENT_LIBRARY_SOURCES)
for key in sorted(_COMPONENT_ENV):
val = _COMPONENT_ENV[key]
if isinstance(val, Island):
@@ -96,6 +101,8 @@ def load_sx_dir(directory: str) -> None:
"""Load all .sx files from a directory and register components.
Skips boundary.sx — those are parsed separately by the boundary validator.
Files starting with ``;; @client`` have their source stored for delivery
to the browser (so ``define`` forms are available client-side).
"""
for filepath in sorted(
glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
@@ -103,7 +110,17 @@ def load_sx_dir(directory: str) -> None:
if os.path.basename(filepath) == "boundary.sx":
continue
with open(filepath, encoding="utf-8") as f:
register_components(f.read())
source = f.read()
if source.lstrip().startswith(";; @client"):
# Parse and re-serialize to normalize syntax sugar.
# The Python parser accepts ' for quote but the bootstrapped
# client parser uses #' — re-serializing emits (quote x).
from .parser import parse_all, serialize
exprs = parse_all(source)
_CLIENT_LIBRARY_SOURCES.append(
"\n".join(serialize(e) for e in exprs)
)
register_components(source)
# ---------------------------------------------------------------------------
@@ -150,6 +167,7 @@ def reload_if_changed() -> None:
_logger.info("Changed: %s", fp)
t0 = time.monotonic()
_COMPONENT_ENV.clear()
_CLIENT_LIBRARY_SOURCES.clear()
# Reload SX libraries first (e.g. z3.sx) so reader macros resolve
for cb in _reload_callbacks:
cb()
@@ -368,9 +386,10 @@ def client_components_tag(*names: str) -> str:
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
if not parts:
if not parts and not _CLIENT_LIBRARY_SOURCES:
return ""
source = "\n".join(parts)
all_parts = list(_CLIENT_LIBRARY_SOURCES) + parts
source = "\n".join(all_parts)
return f'<script type="text/sx" data-components>{source}</script>'
@@ -437,10 +456,12 @@ def components_for_page(page_sx: str, service: str | None = None) -> tuple[str,
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
if not parts:
if not parts and not _CLIENT_LIBRARY_SOURCES:
return "", ""
source = "\n".join(parts)
# Prepend client library sources (define forms) before component defs
all_parts = list(_CLIENT_LIBRARY_SOURCES) + parts
source = "\n".join(all_parts)
digest = hashlib.sha256(source.encode()).hexdigest()[:12]
return source, digest

View File

@@ -630,7 +630,7 @@
(env-set! local "children" child-frag)))
;; Create the island container element
(let ((container (dom-create-element "div" nil))
(let ((container (dom-create-element "span" nil))
(disposers (list)))
;; Mark as island

View File

@@ -349,13 +349,13 @@
(let ((body-html (render-to-html (component-body island) local))
(state-json (serialize-island-state kwargs)))
;; Wrap in container with hydration attributes
(str "<div data-sx-island=\"" (escape-attr island-name) "\""
(str "<span data-sx-island=\"" (escape-attr island-name) "\""
(if state-json
(str " data-sx-state=\"" (escape-attr state-json) "\"")
"")
">"
body-html
"</div>"))))))
"</span>"))))))
;; --------------------------------------------------------------------------

View File

@@ -400,12 +400,19 @@
(define dispose-islands-in
(fn (root)
;; Dispose all islands within root before a swap replaces them.
;; Dispose islands within root, but SKIP hydrated islands —
;; they may be preserved across morphs. Only dispose islands
;; that are not currently hydrated (e.g. freshly parsed content
;; being discarded) or that have been explicitly detached.
(when root
(let ((islands (dom-query-all root "[data-sx-island]")))
(when (and islands (not (empty? islands)))
(log-info (str "disposing " (len islands) " island(s)"))
(for-each dispose-island islands))))))
(let ((to-dispose (filter
(fn (el) (not (is-processed? el "island-hydrated")))
islands)))
(when (not (empty? to-dispose))
(log-info (str "disposing " (len to-dispose) " island(s)"))
(for-each dispose-island to-dispose))))))))
;; --------------------------------------------------------------------------

View File

@@ -3,14 +3,14 @@
Bootstrap compiler: reference SX evaluator → JavaScript.
Reads the .sx reference specification and emits a standalone JavaScript
evaluator (sx-ref.js) that can be compared against the hand-written sx.js.
evaluator (sx-browser.js) that runs in the browser.
The compiler translates the restricted SX subset used in eval.sx/render.sx
into idiomatic JavaScript. Platform interface functions are emitted as
native JS implementations.
Usage:
python bootstrap_js.py > sx-ref.js
python bootstrap_js.py
"""
from __future__ import annotations
@@ -2911,8 +2911,9 @@ PLATFORM_DOM_JS = """
if (!_hasDom || !el) return function() {};
// Wrap SX lambdas from runtime-evaluated island code into native fns
var wrapped = isLambda(handler)
? function(e) { invoke(handler, e); }
? function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
: handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
el.addEventListener(name, wrapped);
return function() { el.removeEventListener(name, wrapped); };
}
@@ -4365,8 +4366,9 @@ if __name__ == "__main__":
help="Comma-separated extensions (continuations). Default: none.")
p.add_argument("--spec-modules",
help="Comma-separated spec modules (deps). Default: none.")
p.add_argument("--output", "-o",
help="Output file (default: stdout)")
default_output = os.path.join(os.path.dirname(__file__), "..", "..", "static", "scripts", "sx-browser.js")
p.add_argument("--output", "-o", default=default_output,
help="Output file (default: shared/static/scripts/sx-browser.js)")
args = p.parse_args()
adapters = args.adapters.split(",") if args.adapters else None
@@ -4375,13 +4377,10 @@ if __name__ == "__main__":
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
js = compile_ref_to_js(adapters, modules, extensions, spec_modules)
if args.output:
with open(args.output, "w") as f:
f.write(js)
included = ", ".join(adapters) if adapters else "all"
mods = ", ".join(modules) if modules else "all"
ext_label = ", ".join(extensions) if extensions else "none"
print(f"Wrote {args.output} ({len(js)} bytes, adapters: {included}, modules: {mods}, extensions: {ext_label})",
file=sys.stderr)
else:
print(js)
with open(args.output, "w") as f:
f.write(js)
included = ", ".join(adapters) if adapters else "all"
mods = ", ".join(modules) if modules else "all"
ext_label = ", ".join(extensions) if extensions else "none"
print(f"Wrote {args.output} ({len(js)} bytes, adapters: {included}, modules: {mods}, extensions: {ext_label})",
file=sys.stderr)

View File

@@ -338,6 +338,16 @@
(dom-has-attr? old-node "sx-ignore"))
nil
;; Hydrated island → preserve reactive state across server swaps.
;; If old and new are the same island (by name), keep the old DOM
;; with its live signals, effects, and event listeners intact.
(and (dom-has-attr? old-node "data-sx-island")
(is-processed? old-node "island-hydrated")
(dom-has-attr? new-node "data-sx-island")
(= (dom-get-attr old-node "data-sx-island")
(dom-get-attr new-node "data-sx-island")))
nil
;; Different node type or tag → replace wholesale
(or (not (= (dom-node-type old-node) (dom-node-type new-node)))
(not (= (dom-node-name old-node) (dom-node-name new-node))))

File diff suppressed because it is too large Load Diff

219
shared/sx/templates/cssx.sx Normal file
View File

@@ -0,0 +1,219 @@
;; @client — send all define forms to browser for client-side use.
;; CSSX — computed CSS from s-expressions.
;;
;; Generic mechanism: cssx is a macro that groups CSS property declarations.
;; The vocabulary (property mappings, value functions) is pluggable — the
;; Tailwind-inspired defaults below are just one possible style system.
;;
;; Usage:
;; (cssx (:text (colour "violet" 699) (size "4xl") (weight "bold") (family "mono"))
;; (:bg (colour "stone" 50)))
;;
;; Each group is (:keyword value ...modifiers):
;; - keyword maps to a CSS property via cssx-properties dict
;; - value is the CSS value for that property
;; - modifiers are extra CSS declaration strings, concatenated in
;;
;; Single group:
;; (cssx (:text (colour "violet" 699)))
;;
;; Modifiers without a colour:
;; (cssx (:text nil (size "4xl") (weight "bold")))
;;
;; Unknown keywords pass through as raw CSS property names:
;; (cssx (:outline (colour "red" 500))) → "outline:hsl(0,72%,53%);"
;;
;; Standalone modifiers work outside cssx too:
;; :style (size "4xl")
;; :style (str (weight "bold") (family "mono"))
;; =========================================================================
;; Layer 1: Generic mechanism — cssx macro + cssxgroup function
;; =========================================================================
;; Property keyword → CSS property name. Extend this dict for new mappings.
(define cssx-properties
{"text" "color"
"bg" "background-color"
"border" "border-color"})
;; Evaluate one property group: (:text value modifier1 modifier2 ...)
;; If value is nil, only modifiers are emitted (no property declaration).
;; NOTE: name must NOT contain hyphens — the evaluator's isRenderExpr check
;; treats (hyphenated-name :keyword ...) as a custom HTML element.
(define cssxgroup
(fn (prop value b c d e)
(let ((css-prop (or (get cssx-properties prop) prop)))
(str (if (nil? value) "" (str css-prop ":" value ";"))
(or b "") (or c "") (or d "") (or e "")))))
;; cssx macro — takes one or more property groups, expands to (str ...).
;; (cssx (:text val ...) (:bg val ...))
;; → (str (cssxgroup :text val ...) (cssxgroup :bg val ...))
(defmacro cssx (&rest groups)
`(str ,@(map (fn (g) (cons 'cssxgroup g)) groups)))
;; =========================================================================
;; Layer 2: Value vocabulary — colour, size, weight, family
;; These are independent functions. Use inside cssx groups or standalone.
;; Replace or extend with any style system.
;; =========================================================================
;; ---------------------------------------------------------------------------
;; Colour — compute CSS colour value from name + shade
;; ---------------------------------------------------------------------------
(define colour-bases
{"violet" {"h" 263 "s" 70}
"purple" {"h" 271 "s" 81}
"indigo" {"h" 239 "s" 84}
"blue" {"h" 217 "s" 91}
"sky" {"h" 199 "s" 89}
"cyan" {"h" 188 "s" 94}
"teal" {"h" 173 "s" 80}
"emerald" {"h" 160 "s" 84}
"green" {"h" 142 "s" 71}
"lime" {"h" 84 "s" 78}
"yellow" {"h" 48 "s" 96}
"amber" {"h" 38 "s" 92}
"orange" {"h" 25 "s" 95}
"red" {"h" 0 "s" 72}
"rose" {"h" 350 "s" 89}
"pink" {"h" 330 "s" 81}
"stone" {"h" 25 "s" 6}
"slate" {"h" 215 "s" 16}
"gray" {"h" 220 "s" 9}
"zinc" {"h" 240 "s" 5}
"neutral" {"h" 0 "s" 0}})
(define lerp (fn (a b t) (+ a (* t (- b a)))))
(define shade-to-lightness
(fn (shade)
(cond
(<= shade 50) (lerp 100 97 (/ shade 50))
(<= shade 100) (lerp 97 93 (/ (- shade 50) 50))
(<= shade 200) (lerp 93 87 (/ (- shade 100) 100))
(<= shade 300) (lerp 87 77 (/ (- shade 200) 100))
(<= shade 400) (lerp 77 64 (/ (- shade 300) 100))
(<= shade 500) (lerp 64 53 (/ (- shade 400) 100))
(<= shade 600) (lerp 53 45 (/ (- shade 500) 100))
(<= shade 700) (lerp 45 38 (/ (- shade 600) 100))
(<= shade 800) (lerp 38 30 (/ (- shade 700) 100))
(<= shade 900) (lerp 30 21 (/ (- shade 800) 100))
(<= shade 950) (lerp 21 13 (/ (- shade 900) 50))
true 13)))
(define colour
(fn (name shade)
(let ((base (get colour-bases name)))
(if (nil? base)
name
(let ((h (get base "h"))
(s (get base "s"))
(l (shade-to-lightness shade)))
(str "hsl(" h "," s "%," (round l) "%)"))))))
;; ---------------------------------------------------------------------------
;; Font sizes — named size → font-size + line-height (Tailwind v3 scale)
;; ---------------------------------------------------------------------------
(define cssx-sizes
{"xs" "font-size:0.75rem;line-height:1rem;"
"sm" "font-size:0.875rem;line-height:1.25rem;"
"base" "font-size:1rem;line-height:1.5rem;"
"lg" "font-size:1.125rem;line-height:1.75rem;"
"xl" "font-size:1.25rem;line-height:1.75rem;"
"2xl" "font-size:1.5rem;line-height:2rem;"
"3xl" "font-size:1.875rem;line-height:2.25rem;"
"4xl" "font-size:2.25rem;line-height:2.5rem;"
"5xl" "font-size:3rem;line-height:1;"
"6xl" "font-size:3.75rem;line-height:1;"
"7xl" "font-size:4.5rem;line-height:1;"
"8xl" "font-size:6rem;line-height:1;"
"9xl" "font-size:8rem;line-height:1;"})
;; ---------------------------------------------------------------------------
;; Font weights — named weight → numeric value
;; ---------------------------------------------------------------------------
(define cssx-weights
{"thin" "100"
"extralight" "200"
"light" "300"
"normal" "400"
"medium" "500"
"semibold" "600"
"bold" "700"
"extrabold" "800"
"black" "900"})
;; ---------------------------------------------------------------------------
;; Font families — named family → CSS font stack
;; ---------------------------------------------------------------------------
(define cssx-families
{"sans" "ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,\"Noto Sans\",sans-serif"
"serif" "ui-serif,Georgia,Cambria,\"Times New Roman\",Times,serif"
"mono" "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace"})
;; ---------------------------------------------------------------------------
;; Standalone modifier functions — return CSS declaration strings
;; Each returns a complete CSS declaration string. Use inside cssx groups
;; or standalone on :style with str.
;; ---------------------------------------------------------------------------
;; -- Typography --
(define size
(fn (s) (or (get cssx-sizes s) (str "font-size:" s ";"))))
(define weight
(fn (w)
(let ((v (get cssx-weights w)))
(str "font-weight:" (or v w) ";"))))
(define family
(fn (f)
(let ((v (get cssx-families f)))
(str "font-family:" (or v f) ";"))))
(define align
(fn (a) (str "text-align:" a ";")))
(define decoration
(fn (d) (str "text-decoration:" d ";")))
;; -- Spacing (Tailwind scale: 1 unit = 0.25rem) --
(define spacing (fn (n) (str (* n 0.25) "rem")))
(define p (fn (n) (str "padding:" (spacing n) ";")))
(define px (fn (n) (str "padding-left:" (spacing n) ";padding-right:" (spacing n) ";")))
(define py (fn (n) (str "padding-top:" (spacing n) ";padding-bottom:" (spacing n) ";")))
(define pt (fn (n) (str "padding-top:" (spacing n) ";")))
(define pb (fn (n) (str "padding-bottom:" (spacing n) ";")))
(define pl (fn (n) (str "padding-left:" (spacing n) ";")))
(define pr (fn (n) (str "padding-right:" (spacing n) ";")))
(define m (fn (n) (str "margin:" (spacing n) ";")))
(define mx (fn (n) (str "margin-left:" (spacing n) ";margin-right:" (spacing n) ";")))
(define my (fn (n) (str "margin-top:" (spacing n) ";margin-bottom:" (spacing n) ";")))
(define mt (fn (n) (str "margin-top:" (spacing n) ";")))
(define mb (fn (n) (str "margin-bottom:" (spacing n) ";")))
(define ml (fn (n) (str "margin-left:" (spacing n) ";")))
(define mr (fn (n) (str "margin-right:" (spacing n) ";")))
(define mx-auto (fn () "margin-left:auto;margin-right:auto;"))
;; -- Display & layout --
(define display (fn (d) (str "display:" d ";")))
(define max-w (fn (w) (str "max-width:" w ";")))
;; Named max-widths (Tailwind scale)
(define cssx-max-widths
{"xs" "20rem" "sm" "24rem" "md" "28rem"
"lg" "32rem" "xl" "36rem" "2xl" "42rem"
"3xl" "48rem" "4xl" "56rem" "5xl" "64rem"
"6xl" "72rem" "7xl" "80rem"
"full" "100%" "none" "none"})