diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js
index 8a3e988..5827d98 100644
--- a/shared/static/scripts/sx-browser.js
+++ b/shared/static/scripts/sx-browser.js
@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
- var SX_VERSION = "2026-03-13T04:50:39Z";
+ var SX_VERSION = "2026-03-13T05:11:12Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1934,7 +1934,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
})() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? (function() {
var child = renderToDom(arg, env, newNs);
- return (isSxTruthy(isSpread(child)) ? forEach(function(key) { return (function() {
+ return (isSxTruthy((isSxTruthy(isSpread(child)) && _islandScope)) ? reactiveSpread(el, function() { return renderToDom(arg, env, newNs); }) : (isSxTruthy(isSpread(child)) ? forEach(function(key) { return (function() {
var val = dictGet(spreadAttrs(child), key);
return (isSxTruthy((key == "class")) ? (function() {
var existing = domGetAttr(el, "class");
@@ -1943,7 +1943,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme
var existing = domGetAttr(el, "style");
return domSetAttr(el, "style", (isSxTruthy((isSxTruthy(existing) && !isSxTruthy((existing == "")))) ? (String(existing) + String(";") + String(val)) : val));
})() : domSetAttr(el, key, (String(val)))));
-})(); }, keys(spreadAttrs(child))) : domAppend(el, child));
+})(); }, keys(spreadAttrs(child))) : domAppend(el, child)));
})() : NIL), assoc(state, "i", (get(state, "i") + 1)))));
})(); }, {["i"]: 0, ["skip"]: false}, args);
return el;
@@ -2269,6 +2269,44 @@ return effect(function() { return (function() {
})();
})(); }); };
+ // reactive-spread
+ var reactiveSpread = function(el, renderFn) { return (function() {
+ var prevClasses = [];
+ var prevExtraKeys = [];
+ (function() {
+ var existing = sxOr(domGetAttr(el, "data-sx-reactive-attrs"), "");
+ return domSetAttr(el, "data-sx-reactive-attrs", (isSxTruthy(isEmpty(existing)) ? "_spread" : (String(existing) + String(",_spread"))));
+})();
+ return effect(function() { if (isSxTruthy(!isSxTruthy(isEmpty(prevClasses)))) {
+ (function() {
+ var current = sxOr(domGetAttr(el, "class"), "");
+ var tokens = filter(function(c) { return !isSxTruthy((c == "")); }, split(current, " "));
+ var kept = filter(function(c) { return !isSxTruthy(some(function(pc) { return (pc == c); }, prevClasses)); }, tokens);
+ return (isSxTruthy(isEmpty(kept)) ? domRemoveAttr(el, "class") : domSetAttr(el, "class", join(" ", kept)));
+})();
+}
+{ var _c = prevExtraKeys; for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; domRemoveAttr(el, k); } }
+return (function() {
+ var result = renderFn();
+ return (isSxTruthy(isSpread(result)) ? (function() {
+ var attrs = spreadAttrs(result);
+ var clsStr = sxOr(dictGet(attrs, "class"), "");
+ var newClasses = filter(function(c) { return !isSxTruthy((c == "")); }, split(clsStr, " "));
+ var extraKeys = filter(function(k) { return !isSxTruthy((k == "class")); }, keys(attrs));
+ prevClasses = newClasses;
+ prevExtraKeys = extraKeys;
+ if (isSxTruthy(!isSxTruthy(isEmpty(newClasses)))) {
+ (function() {
+ var current = sxOr(domGetAttr(el, "class"), "");
+ return domSetAttr(el, "class", (isSxTruthy((isSxTruthy(current) && !isSxTruthy((current == "")))) ? (String(current) + String(" ") + String(clsStr)) : clsStr));
+})();
+}
+ { var _c = extraKeys; for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; domSetAttr(el, k, (String(dictGet(attrs, k)))); } }
+ return flushCssxToDom();
+})() : ((prevClasses = []), (prevExtraKeys = [])));
+})(); });
+})(); };
+
// reactive-fragment
var reactiveFragment = function(testFn, renderFn, env, ns) { return (function() {
var marker = createComment("island-fragment");
diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx
index 2b3c315..40edf4f 100644
--- a/shared/sx/ref/adapter-dom.sx
+++ b/shared/sx/ref/adapter-dom.sx
@@ -232,30 +232,35 @@
(do
(when (not (contains? VOID_ELEMENTS tag))
(let ((child (render-to-dom arg env new-ns)))
- (if (spread? child)
- ;; Spread: merge attrs onto parent element
- (for-each
- (fn ((key :as string))
- (let ((val (dict-get (spread-attrs child) key)))
- (if (= key "class")
- ;; Class: append to existing
- (let ((existing (dom-get-attr el "class")))
- (dom-set-attr el "class"
- (if (and existing (not (= existing "")))
- (str existing " " val)
- val)))
- (if (= key "style")
- ;; Style: append with semicolon
- (let ((existing (dom-get-attr el "style")))
- (dom-set-attr el "style"
+ (cond
+ ;; Reactive spread: track signal deps, update attrs on change
+ (and (spread? child) *island-scope*)
+ (reactive-spread el (fn () (render-to-dom arg env new-ns)))
+ ;; Static spread: one-shot merge attrs onto parent element
+ (spread? child)
+ (for-each
+ (fn ((key :as string))
+ (let ((val (dict-get (spread-attrs child) key)))
+ (if (= key "class")
+ ;; Class: append to existing
+ (let ((existing (dom-get-attr el "class")))
+ (dom-set-attr el "class"
(if (and existing (not (= existing "")))
- (str existing ";" val)
+ (str existing " " val)
val)))
- ;; Other attrs: overwrite
- (dom-set-attr el key (str val))))))
- (keys (spread-attrs child)))
+ (if (= key "style")
+ ;; Style: append with semicolon
+ (let ((existing (dom-get-attr el "style")))
+ (dom-set-attr el "style"
+ (if (and existing (not (= existing "")))
+ (str existing ";" val)
+ val)))
+ ;; Other attrs: overwrite
+ (dom-set-attr el key (str val))))))
+ (keys (spread-attrs child)))
;; Normal child: append to element
- (dom-append el child))))
+ :else
+ (dom-append el child))))
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
@@ -867,6 +872,64 @@
:else
(dom-set-attr el attr-name (str val)))))))))
+;; reactive-spread — reactively bind spread attrs to parent element.
+;; Used when a child of an element produces a spread inside an island.
+;; Tracks signal deps in the spread expression. When signals change:
+;; old classes are removed, new ones applied. Non-class attrs (data-tw etc.)
+;; are overwritten. Flushes newly collected CSS rules to live stylesheet.
+;;
+;; Multiple reactive spreads on the same element are safe — each tracks
+;; its own class contribution and only removes/adds its own tokens.
+(define reactive-spread :effects [render mutation]
+ (fn (el (render-fn :as lambda))
+ (let ((prev-classes (list))
+ (prev-extra-keys (list)))
+ ;; Mark for morph protection
+ (let ((existing (or (dom-get-attr el "data-sx-reactive-attrs") "")))
+ (dom-set-attr el "data-sx-reactive-attrs"
+ (if (empty? existing) "_spread" (str existing ",_spread"))))
+ (effect (fn ()
+ ;; 1. Remove previously applied classes from element's class list
+ (when (not (empty? prev-classes))
+ (let ((current (or (dom-get-attr el "class") ""))
+ (tokens (filter (fn (c) (not (= c ""))) (split current " ")))
+ (kept (filter (fn (c)
+ (not (some (fn (pc) (= pc c)) prev-classes)))
+ tokens)))
+ (if (empty? kept)
+ (dom-remove-attr el "class")
+ (dom-set-attr el "class" (join " " kept)))))
+ ;; 2. Remove previously applied extra attrs
+ (for-each (fn (k) (dom-remove-attr el k)) prev-extra-keys)
+ ;; 3. Re-evaluate the spread expression (tracks signal deps)
+ (let ((result (render-fn)))
+ (if (spread? result)
+ (let ((attrs (spread-attrs result))
+ (cls-str (or (dict-get attrs "class") ""))
+ (new-classes (filter (fn (c) (not (= c "")))
+ (split cls-str " ")))
+ (extra-keys (filter (fn (k) (not (= k "class")))
+ (keys attrs))))
+ (set! prev-classes new-classes)
+ (set! prev-extra-keys extra-keys)
+ ;; Append new classes to element
+ (when (not (empty? new-classes))
+ (let ((current (or (dom-get-attr el "class") "")))
+ (dom-set-attr el "class"
+ (if (and current (not (= current "")))
+ (str current " " cls-str)
+ cls-str))))
+ ;; Set extra attrs (data-tw, etc.) — simple overwrite
+ (for-each (fn (k)
+ (dom-set-attr el k (str (dict-get attrs k))))
+ extra-keys)
+ ;; Flush any newly collected CSS rules to live stylesheet
+ (flush-cssx-to-dom))
+ ;; No longer a spread — clear tracked state
+ (do
+ (set! prev-classes (list))
+ (set! prev-extra-keys (list))))))))))
+
;; reactive-fragment — conditionally render a fragment based on a signal
;; Used for (when (deref sig) ...) or (if (deref sig) ...) inside an island.
(define reactive-fragment :effects [render mutation]
diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx
index 24ade0c..efd7081 100644
--- a/sx/sx/nav-data.sx
+++ b/sx/sx/nav-data.sx
@@ -370,6 +370,8 @@
:children (list
{:label "Reference" :href "/sx/(geography.(hypermedia.(reference)))" :children reference-nav-items}
{:label "Examples" :href "/sx/(geography.(hypermedia.(example)))" :children examples-nav-items})}
+ {:label "Spreads" :href "/sx/(geography.(spreads))"
+ :summary "Child-to-parent communication across render boundaries — spread, collect!, reactive-spread, and the path to provide/context/emit!."}
{:label "Marshes" :href "/sx/(geography.(marshes))"
:summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms reshape server content, client state modifies how hypermedia is interpreted."}
{:label "Isomorphism" :href "/sx/(geography.(isomorphism))" :children isomorphism-nav-items})}
diff --git a/sx/sx/spreads.sx b/sx/sx/spreads.sx
new file mode 100644
index 0000000..0558f3b
--- /dev/null
+++ b/sx/sx/spreads.sx
@@ -0,0 +1,235 @@
+;; ---------------------------------------------------------------------------
+;; Spreads — child-to-parent communication across render boundaries
+;; ---------------------------------------------------------------------------
+
+(defcomp ~geography/spreads-content ()
+ (~docs/page :title "Spreads"
+
+ (p :class "text-stone-500 text-sm italic mb-8"
+ "A spread is a value that, when returned as a child of an element, "
+ "injects attributes onto its parent instead of rendering as content. "
+ "This inverts the normal direction of data flow: children tell parents how to look.")
+
+ ;; =====================================================================
+ ;; I. The primitives
+ ;; =====================================================================
+
+ (~docs/section :title "Three primitives" :id "primitives"
+ (p "The spread system has three orthogonal primitives. Each operates at a "
+ "different level of the render pipeline.")
+
+ (~docs/subsection :title "1. make-spread / spread? / spread-attrs"
+ (p "A spread is a value type. " (code "make-spread") " creates one from a dict of "
+ "attributes. When the renderer encounters a spread as a child of an element, "
+ "it merges the attrs onto the parent element instead of appending a DOM node.")
+ (~docs/code :code "(defcomp ~highlight (&key colour)
+ (make-spread {\"class\" (str \"highlight-\" colour)
+ \"data-highlight\" colour}))")
+ (p "Use it as a child of any element:")
+ (~docs/code :code "(div (~highlight :colour \"yellow\")
+ \"This div gets class=highlight-yellow\")")
+ (p (code "class") " values are appended (space-joined). "
+ (code "style") " values are appended (semicolon-joined). "
+ "All other attributes overwrite."))
+
+ (~docs/subsection :title "2. collect! / collected / clear-collected!"
+ (p "Render-time accumulators. Values are collected into named buckets "
+ "during rendering and retrieved at flush points. Deduplication is automatic.")
+ (~docs/code :code ";; Deep inside a component tree:
+(collect! \"cssx\" \".sx-bg-red-500{background-color:hsl(0,72%,53%)}\")
+
+;; At the flush point (once, in the layout):
+(let ((rules (collected \"cssx\")))
+ (clear-collected! \"cssx\")
+ (raw! (str \"\")))")
+ (p "This is upward communication through the render tree: "
+ "a deeply nested component contributes a CSS rule, and the layout "
+ "emits all accumulated rules as a single " (code "