HS parser: fix number+comparison keyword collision, eval-hs uses hs-compile

Parser: skip unit suffix when next ident is a comparison keyword
(starts, ends, contains, matches, is, does, in, precedes, follows).
Fixes "123 starts with '12'" returning "123starts" instead of true.

eval-hs: use hs-compile directly instead of hs-to-sx-from-source with
"return " prefix, which was causing the parser to consume the comparison
as a string suffix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 11:29:01 +00:00
parent a93e5924df
commit 4f02f82f4e
377 changed files with 9517 additions and 8694 deletions

View File

@@ -1,5 +1,4 @@
(defcomp
~geography/capabilities-content
()
(~docs/page
:title "Capabilities"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
(defisland
()
(let
((count (signal 0))
(name (signal "world"))
(cid-display (signal ""))
(cid-input (signal ""))
(cid-history (signal (list)))
(status (signal "")))
(freeze-scope
"ca-demo"
(fn () (freeze-signal "count" count) (freeze-signal "name" name)))
(div
(~tw :tokens "space-y-4")
(div
(~tw :tokens "flex gap-4 items-center")
(div
(~tw :tokens "flex items-center gap-2")
(button
:on-click (fn (e) (swap! count dec))
(~tw
:tokens "px-2 py-1 rounded bg-stone-200 text-stone-700 hover:bg-stone-300")
"-")
(span
(~tw :tokens "font-mono text-lg font-bold w-8 text-center")
(deref count))
(button
:on-click (fn (e) (swap! count inc))
(~tw
:tokens "px-2 py-1 rounded bg-stone-200 text-stone-700 hover:bg-stone-300")
"+"))
(input
:type "text"
:bind name
(~tw
:tokens "px-3 py-1 rounded border border-stone-300 font-mono text-sm")))
(div
(~tw
:tokens "rounded bg-violet-50 border border-violet-200 p-3 text-violet-800")
"Hello, "
(deref name)
"! Count = "
(deref count))
(div
(~tw :tokens "flex gap-2")
(button
:on-click (fn
(e)
(let
((cid (freeze-to-cid "ca-demo")))
(reset! cid-display cid)
(reset! status (str "Stored as " cid))
(swap! cid-history (fn (h) (append h (list cid))))))
(~tw
:tokens "px-3 py-1.5 rounded bg-stone-700 text-white text-sm hover:bg-stone-800")
"Content-address"))
(when
(not (empty? (deref cid-display)))
(div
(~tw
:tokens "font-mono text-sm bg-stone-50 rounded p-2 flex items-center gap-2")
(span (~tw :tokens "text-stone-400") "CID:")
(span
(~tw :tokens "text-violet-700 font-bold")
(deref cid-display))))
(when
(not (empty? (deref status)))
(div (~tw :tokens "text-xs text-emerald-600") (deref status)))
(div
(~tw :tokens "flex gap-2 items-end")
(div
(~tw :tokens "flex-1")
(label
(~tw :tokens "text-xs text-stone-400 block mb-1")
"Restore from CID")
(input
:type "text"
:bind cid-input
(~tw
:tokens "w-full px-3 py-1 rounded border border-stone-300 font-mono text-sm")))
(button
:on-click (fn
(e)
(if
(thaw-from-cid (deref cid-input))
(reset! status (str "Restored from " (deref cid-input)))
(reset! status (str "CID not found: " (deref cid-input)))))
(~tw
:tokens "px-3 py-1.5 rounded bg-emerald-600 text-white text-sm hover:bg-emerald-700")
"Restore"))
(when
(not (empty? (deref cid-history)))
(div
(~tw :tokens "space-y-1")
(label (~tw :tokens "text-xs text-stone-400 block") "History")
(map
(fn
(cid)
(button
:on-click (fn
(e)
(if
(thaw-from-cid cid)
(reset! status (str "Restored from " cid))
(reset! status (str "CID not found: " cid))))
(~tw
:tokens "block w-full text-left px-2 py-1 rounded bg-stone-50 hover:bg-stone-100 font-mono text-xs text-stone-600")
cid))
(deref cid-history)))))))

View File

@@ -0,0 +1,30 @@
(defisland
()
(let
((first-sig (signal 0))
(second-sig (signal 0))
(renders (signal 0))
(_eff
(effect
(fn () (deref first-sig) (deref second-sig) (swap! renders inc)))))
(div
(~tw :tokens "rounded-lg border border-stone-200 p-4 space-y-2")
(div
(~tw :tokens "flex items-center gap-4 text-sm")
(span (str "first: " (deref first-sig)))
(span (str "second: " (deref second-sig)))
(span
(~tw :tokens "px-2 py-0.5 rounded bg-green-100 text-green-800 text-xs font-semibold")
(str "renders: " (deref renders))))
(div
(~tw :tokens "flex items-center gap-2")
(button
(~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm")
:on-click (fn
(e)
(batch (fn () (swap! first-sig inc) (swap! second-sig inc))))
"Batch +1")
(button
(~tw :tokens "px-3 py-1 rounded bg-stone-400 text-white text-sm")
:on-click (fn (e) (swap! first-sig inc) (swap! second-sig inc))
"No-batch +1")))))

View File

@@ -0,0 +1,28 @@
(defisland
()
(let
((base (signal 1))
(doubled (computed (fn () (* (deref base) 2))))
(quadrupled (computed (fn () (* (deref doubled) 2)))))
(div
(~tw :tokens "rounded-lg border border-stone-200 p-4 space-y-2")
(div
(~tw :tokens "flex items-center gap-3")
(button
(~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm")
:on-click (fn (e) (swap! base dec))
"-")
(span
(~tw :tokens "text-2xl font-bold text-violet-700 min-w-[3ch] text-center")
(deref base))
(button
(~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm")
:on-click (fn (e) (swap! base inc))
"+"))
(p
(~tw :tokens "text-sm text-stone-500")
(str
"doubled: "
(deref doubled)
" | quadrupled: "
(deref quadrupled))))))

View File

@@ -0,0 +1,21 @@
(defisland
(&key initial)
(let
((count (signal (or initial 0)))
(doubled (computed (fn () (* 2 (deref count))))))
(div
(~tw :tokens "rounded-lg border border-stone-200 p-4 space-y-2")
(div
(~tw :tokens "flex items-center gap-3")
(button
(~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm")
:on-click (fn (e) (swap! count dec))
"-")
(span
(~tw :tokens "text-2xl font-bold text-violet-700 min-w-[3ch] text-center")
(deref count))
(button
(~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm")
:on-click (fn (e) (swap! count inc))
"+"))
(p (~tw :tokens "text-sm text-stone-500") (str "doubled: " (deref doubled))))))

View File

@@ -0,0 +1,21 @@
(defisland
()
(let
((danger (signal false)))
(div
(~tw :tokens "rounded-lg border border-stone-200 p-4 space-y-3")
(button
(~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm")
:on-click (fn (e) (swap! danger not))
(if (deref danger) "Safe mode" "Danger mode"))
(div
:class (str
"p-3 rounded font-medium transition-colors "
(if
(deref danger)
"bg-red-100 text-red-800"
"bg-green-100 text-green-800"))
(if
(deref danger)
"DANGER: reactive class binding via CEK"
"SAFE: reactive class binding via CEK")))))

View File

@@ -0,0 +1,256 @@
(defisland
(&key initial-expr)
(let
((source (signal (or initial-expr "(div (~cssx/tw :tokens \"p-6 rounded-lg border border-stone-200 bg-white text-center\")\n (h1 (~cssx/tw :tokens \"text-3xl font-bold mb-2\")\n (span (~cssx/tw :tokens \"text-rose-500\") \"the \")\n (span (~cssx/tw :tokens \"text-amber-500\") \"joy \")\n (span (~cssx/tw :tokens \"text-emerald-500\") \"of \")\n (span (~cssx/tw :tokens \"text-violet-600 text-4xl\") \"sx\")))")))
(steps (signal (list)))
(step-idx (signal 0))
(parsed-ok (signal false))
(error-msg (signal nil))
(dom-stack-sig (signal (list))))
(letrec
((split-tag (fn (expr result) (cond (not (list? expr)) (append! result {:expr expr :type "leaf"}) (empty? expr) nil (not (= (type-of (first expr)) "symbol")) (append! result {:expr expr :type "leaf"}) (is-html-tag? (symbol-name (first expr))) (let ((ctag (symbol-name (first expr))) (cargs (rest expr)) (cch (list)) (cat (list)) (spreads (list)) (ckw false)) (for-each (fn (a) (cond (= (type-of a) "keyword") (do (set! ckw true) (append! cat a)) ckw (do (set! ckw false) (append! cat a)) (and (list? a) (not (empty? a)) (= (type-of (first a)) "symbol") (starts-with? (symbol-name (first a)) "~")) (do (set! ckw false) (append! spreads a)) :else (do (set! ckw false) (append! cch a)))) cargs) (append! result {:spreads spreads :tag ctag :type "open" :attrs cat}) (for-each (fn (c) (split-tag c result)) cch) (append! result {:tag ctag :type "close"})) :else (append! result {:expr expr :type "expr"}))))
(get-preview (fn () (dom-query "[data-sx-lake=\"preview\"]")))
(get-stack (fn () (deref dom-stack-sig)))
(set-stack (fn (v) (reset! dom-stack-sig v)))
(push-stack
(fn
(el)
(reset! dom-stack-sig (append (deref dom-stack-sig) (list el)))))
(pop-stack
(fn
()
(let
((s (deref dom-stack-sig)))
(when
(> (len s) 1)
(reset! dom-stack-sig (slice s 0 (- (len s) 1)))))))
(do-parse
(fn
()
(reset! error-msg nil)
(reset! step-idx 0)
(reset! parsed-ok false)
(set-stack (list))
(let
((container (get-preview)))
(when container (dom-set-prop container "innerHTML" "")))
(let
((parsed (sx-parse (deref source))))
(if
(empty? parsed)
(do (reset! error-msg "Parse error") (reset! steps (list)))
(let
((result (list)))
(split-tag (first parsed) result)
(reset! steps result)
(reset! parsed-ok true)
(set-stack (list (get-preview))))))))
(do-step
(fn
()
(when
(and
(deref parsed-ok)
(< (deref step-idx) (len (deref steps))))
(let
((step (nth (deref steps) (deref step-idx)))
(step-type (get step "type"))
(stack (get-stack))
(parent
(if
(empty? (get-stack))
(get-preview)
(last (get-stack)))))
(cond
(= step-type "open")
(let
((el (dom-create-element (get step "tag") nil))
(attrs (get step "attrs"))
(spreads (or (get step "spreads") (list))))
(let
loop
((i 0))
(when
(< i (len attrs))
(dom-set-attr
el
(keyword-name (nth attrs i))
(nth attrs (+ i 1)))
(loop (+ i 2))))
(for-each
(fn
(sp)
(let
((result (eval-expr sp (make-env))))
(when
(and result (spread? result))
(let
((sattrs (spread-attrs result)))
(for-each
(fn
(k)
(if
(= k "class")
(dom-set-attr
el
"class"
(str
(or (dom-get-attr el "class") "")
" "
(get sattrs k)))
(dom-set-attr el k (get sattrs k))))
(keys sattrs))))))
spreads)
(when parent (dom-append parent el))
(push-stack el))
(= step-type "close")
(pop-stack)
(= step-type "leaf")
(when
parent
(let
((val (get step "expr")))
(dom-append
parent
(create-text-node (if (string? val) val (str val))))))
(= step-type "expr")
(let
((rendered (render-to-dom (get step "expr") (make-env) nil)))
(when (and parent rendered) (dom-append parent rendered)))))
(swap! step-idx inc))))
(do-run
(fn
()
(let
loop
()
(when
(< (deref step-idx) (len (deref steps)))
(do-step)
(loop)))))
(do-back
(fn
()
(when
(and (deref parsed-ok) (> (deref step-idx) 0))
(let
((target (- (deref step-idx) 1))
(container (get-preview)))
(when container (dom-set-prop container "innerHTML" ""))
(set-stack (list (get-preview)))
(reset! step-idx 0)
(for-each
(fn (_) (do-step))
(slice (deref steps) 0 target)))))))
(div
(~tw :tokens "space-y-4")
(div
(label
(~tw :tokens "text-xs text-stone-400 block mb-1")
"Component expression")
(textarea
:bind source
:rows 4
(~tw :tokens "w-full px-3 py-2 rounded border border-stone-300 font-mono text-xs focus:outline-none focus:border-violet-400")))
(div
(~tw :tokens "flex gap-1")
(button
:on-click (fn (e) (do-parse))
(~tw :tokens "px-3 py-1.5 rounded bg-stone-700 text-white text-sm hover:bg-stone-800")
"Parse")
(button
:on-click (fn (e) (do-back))
:class (str
"px-3 py-1.5 rounded text-sm "
(if
(and (deref parsed-ok) (> (deref step-idx) 0))
"bg-stone-200 text-stone-700 hover:bg-stone-300"
"bg-stone-100 text-stone-300 cursor-not-allowed"))
"◀")
(button
:on-click (fn (e) (do-step))
:class (str
"px-3 py-1.5 rounded text-sm "
(if
(and
(deref parsed-ok)
(< (deref step-idx) (len (deref steps))))
"bg-violet-500 text-white hover:bg-violet-600"
"bg-violet-200 text-violet-400 cursor-not-allowed"))
"Step ▶")
(button
:on-click (fn (e) (do-run))
:class (str
"px-3 py-1.5 rounded text-sm "
(if
(deref parsed-ok)
"bg-violet-700 text-white hover:bg-violet-800"
"bg-violet-200 text-violet-400 cursor-not-allowed"))
"Run ▶▶"))
(when
(deref error-msg)
(div (~tw :tokens "text-red-600 text-sm") (deref error-msg)))
(when
(and (deref parsed-ok) (= (deref step-idx) 0))
(div
(~tw :tokens "text-sm text-stone-500 bg-stone-50 rounded p-2")
(str
"Parsed "
(len (deref steps))
" render steps. Click Step to begin.")))
(div
(~tw :tokens "grid grid-cols-1 md:grid-cols-2 gap-4")
(when
(deref parsed-ok)
(div
(~tw :tokens "rounded border border-stone-200 bg-white p-3 min-h-24")
(div
(~tw :tokens "text-xs text-stone-400 mb-2")
(str
(deref step-idx)
" / "
(len (deref steps))
(if
(= (deref step-idx) (len (deref steps)))
" — complete"
"")))
(div
(~tw :tokens "space-y-0.5 font-mono text-xs max-h-64 overflow-y-auto")
(map-indexed
(fn
(i step)
(div
:class (str
"flex gap-2 px-1 rounded "
(cond
(= i (deref step-idx))
"bg-violet-100 text-violet-700 font-bold"
(< i (deref step-idx))
"text-stone-400"
:else "text-stone-300"))
(span
(~tw :tokens "w-4 text-right")
(if (< i (deref step-idx)) "✓" (str (+ i 1))))
(span
(~tw :tokens "truncate")
(let
((lbl (get step "label")))
(if
lbl
(if
(> (len lbl) 60)
(str (slice lbl 0 57) "...")
lbl)
(let
((tp (get step "type")))
(cond
(= tp "open")
(str "<" (get step "tag") ">")
(= tp "close")
(str "</" (get step "tag") ">")
:else (sx-serialize (get step "expr")))))))))
(deref steps)))))
(div
(~tw :tokens "rounded border border-stone-200 p-3 min-h-24")
(div (~tw :tokens "text-xs text-stone-400 mb-2") "Live DOM")
(lake :id "preview")))))))

View File

@@ -0,0 +1,182 @@
(defisland
(&key initial-expr)
(let
((source (signal (or initial-expr "(+ 1 (* 2 3))")))
(state (signal nil))
(steps (signal 0))
(history (signal (list)))
(error-msg (signal nil)))
(define
start-eval
(fn
()
(reset! error-msg nil)
(reset! history (list))
(reset! steps 0)
(let
((parsed (sx-parse (deref source))))
(if
(empty? parsed)
(reset! error-msg "Parse error: empty expression")
(reset!
state
(make-cek-state (first parsed) (make-env) (list)))))))
(define
do-step
(fn
()
(when
(and (deref state) (not (cek-terminal? (deref state))))
(let
((prev (deref state)))
(swap! history (fn (h) (append h (list prev))))
(swap! steps inc)
(reset! state (cek-step prev))))))
(define
do-run
(fn
()
(when
(deref state)
(let
run-loop
((n 0))
(when
(and (not (cek-terminal? (deref state))) (< n 200))
(do-step)
(run-loop (+ n 1)))))))
(define
do-reset
(fn
()
(reset! state nil)
(reset! steps 0)
(reset! history (list))
(reset! error-msg nil)))
(define
fmt-control
(fn
(s)
(if
(nil? s)
"—"
(let
((c (get s "control")))
(if (nil? c) "—" (sx-serialize c))))))
(define
fmt-value
(fn
(s)
(if
(nil? s)
"—"
(let
((v (get s "value")))
(cond
(nil? v)
"nil"
(callable? v)
(str "λ:" (or (lambda-name v) "fn"))
:else (sx-serialize v))))))
(define
fmt-kont
(fn
(s)
(if
(nil? s)
"—"
(let
((k (get s "kont")))
(if
(empty? k)
"[]"
(str "[" (join " " (map (fn (f) (get f "type")) k)) "]"))))))
(start-eval)
(div
(~tw :tokens "space-y-4")
(div
(~tw :tokens "flex gap-2 items-end")
(div
(~tw :tokens "flex-1")
(label (~tw :tokens "text-xs text-stone-400 block mb-1") "Expression")
(input
:type "text"
:bind source
(~tw :tokens "w-full px-3 py-1.5 rounded border border-stone-300 font-mono text-sm focus:outline-none focus:border-violet-400")
:on-change (fn (e) (start-eval))))
(div
(~tw :tokens "flex gap-1")
(button
:on-click (fn (e) (start-eval))
(~tw :tokens "px-3 py-1.5 rounded bg-stone-200 text-stone-700 text-sm hover:bg-stone-300")
"Reset")
(button
:on-click (fn (e) (do-step))
(~tw :tokens "px-3 py-1.5 rounded bg-violet-500 text-white text-sm hover:bg-violet-600")
"Step")
(button
:on-click (fn (e) (do-run))
(~tw :tokens "px-3 py-1.5 rounded bg-violet-700 text-white text-sm hover:bg-violet-800")
"Run")))
(when
(deref error-msg)
(div (~tw :tokens "text-red-600 text-sm") (deref error-msg)))
(when
(deref state)
(div
(~tw :tokens "rounded border border-stone-200 bg-white p-3 font-mono text-sm space-y-1")
(div
(~tw :tokens "flex gap-4")
(span (~tw :tokens "text-stone-400 w-16") "Step")
(span (~tw :tokens "font-bold") (deref steps)))
(div
(~tw :tokens "flex gap-4")
(span (~tw :tokens "text-stone-400 w-16") "Phase")
(span
:class (str
"font-bold "
(if
(= (get (deref state) "phase") "eval")
"text-blue-600"
"text-green-600"))
(get (deref state) "phase")))
(div
(~tw :tokens "flex gap-4")
(span (~tw :tokens "text-violet-500 w-16") "C")
(span (fmt-control (deref state))))
(div
(~tw :tokens "flex gap-4")
(span (~tw :tokens "text-amber-600 w-16") "V")
(span (fmt-value (deref state))))
(div
(~tw :tokens "flex gap-4")
(span (~tw :tokens "text-emerald-600 w-16") "K")
(span (fmt-kont (deref state))))
(when
(cek-terminal? (deref state))
(div
(~tw :tokens "mt-2 pt-2 border-t border-stone-200 text-stone-800 font-bold")
(str "Result: " (sx-serialize (cek-value (deref state))))))))
(when
(not (empty? (deref history)))
(div
(~tw :tokens "rounded border border-stone-100 bg-stone-50 p-2")
(div (~tw :tokens "text-xs text-stone-400 mb-1") "History")
(div
(~tw :tokens "space-y-0.5 font-mono text-xs max-h-48 overflow-y-auto")
(map-indexed
(fn
(i s)
(div
(~tw :tokens "flex gap-2 text-stone-500")
(span (~tw :tokens "text-stone-300 w-6 text-right") (+ i 1))
(span
:class (if
(= (get s "phase") "eval")
"text-blue-400"
"text-green-400")
(get s "phase"))
(span (~tw :tokens "text-violet-400 truncate") (fmt-control s))
(span (~tw :tokens "text-amber-400") (fmt-value s))
(span (~tw :tokens "text-emerald-400") (fmt-kont s))))
(deref history))))))))

View File

@@ -0,0 +1,47 @@
(defisland
()
(let
((running (signal false))
(elapsed (signal 0))
(time-text (create-text-node "0.0s"))
(btn-text (create-text-node "Start"))
(_e1
(effect
(fn
()
(when
(deref running)
(let
((id (set-interval (fn () (swap! elapsed inc)) 100)))
(fn () (clear-interval id)))))))
(_e2
(effect
(fn
()
(let
((e (deref elapsed)))
(dom-set-text-content
time-text
(str (floor (/ e 10)) "." (mod e 10) "s"))))))
(_e3
(effect
(fn
()
(dom-set-text-content
btn-text
(if (deref running) "Stop" "Start"))))))
(div
(~tw :tokens "rounded-lg border border-stone-200 p-4")
(div
(~tw :tokens "flex items-center gap-3")
(span
(~tw :tokens "text-2xl font-bold text-violet-700 font-mono min-w-[5ch]")
time-text)
(button
(~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm")
:on-click (fn (e) (swap! running not))
btn-text)
(button
(~tw :tokens "px-3 py-1 rounded bg-stone-400 text-white text-sm")
:on-click (fn (e) (reset! running false) (reset! elapsed 0))
"Reset")))))

View File

@@ -0,0 +1,160 @@
(defisland
()
(let
((bg (signal "violet"))
(size (signal "text-2xl"))
(weight (signal "font-bold"))
(text (signal "the joy of sx"))
(saved (signal (list))))
(freeze-scope
"widget"
(fn
()
(freeze-signal "bg" bg)
(freeze-signal "size" size)
(freeze-signal "weight" weight)
(freeze-signal "text" text)))
(span
(~tw
:tokens "hidden bg-violet-50 bg-rose-50 bg-emerald-50 bg-amber-50 bg-sky-50 bg-stone-50 border-violet-200 border-rose-200 border-emerald-200 border-amber-200 border-sky-200 border-stone-200 text-violet-700 text-rose-700 text-emerald-700 text-amber-700 text-sky-700 text-stone-700"))
(div
(~tw :tokens "space-y-4")
(div
(~tw :tokens "p-6 rounded-lg text-center transition-all border")
:class (str "bg-" (deref bg) "-50 border-" (deref bg) "-200")
(span
:class (str (deref size) " " (deref weight) " text-" (deref bg) "-700")
(deref text)))
(div
(~tw :tokens "grid grid-cols-2 gap-3")
(div
(label (~tw :tokens "text-xs text-stone-400 block mb-1") "Colour")
(div
(~tw :tokens "flex gap-1")
(button
:on-click (fn (e) (reset! bg "violet"))
(~tw :tokens "w-8 h-8 rounded-full")
:class (str
"bg-violet-400"
(if
(= (deref bg) "violet")
" ring-2 ring-offset-1 ring-stone-400"
""))
"")
(button
:on-click (fn (e) (reset! bg "rose"))
(~tw :tokens "w-8 h-8 rounded-full")
:class (str
"bg-rose-400"
(if
(= (deref bg) "rose")
" ring-2 ring-offset-1 ring-stone-400"
""))
"")
(button
:on-click (fn (e) (reset! bg "emerald"))
(~tw :tokens "w-8 h-8 rounded-full")
:class (str
"bg-emerald-400"
(if
(= (deref bg) "emerald")
" ring-2 ring-offset-1 ring-stone-400"
""))
"")
(button
:on-click (fn (e) (reset! bg "amber"))
(~tw :tokens "w-8 h-8 rounded-full")
:class (str
"bg-amber-400"
(if
(= (deref bg) "amber")
" ring-2 ring-offset-1 ring-stone-400"
""))
"")
(button
:on-click (fn (e) (reset! bg "sky"))
(~tw :tokens "w-8 h-8 rounded-full")
:class (str
"bg-sky-400"
(if
(= (deref bg) "sky")
" ring-2 ring-offset-1 ring-stone-400"
""))
"")
(button
:on-click (fn (e) (reset! bg "stone"))
(~tw :tokens "w-8 h-8 rounded-full")
:class (str
"bg-stone-400"
(if
(= (deref bg) "stone")
" ring-2 ring-offset-1 ring-stone-400"
""))
"")))
(div
(label (~tw :tokens "text-xs text-stone-400 block mb-1") "Size")
(div
(~tw :tokens "flex gap-1")
(map
(fn
(s)
(button
:on-click (fn (e) (reset! size s))
(~tw :tokens "px-2 py-1 rounded text-xs")
:class (if
(= (deref size) s)
"bg-stone-700 text-white"
"bg-stone-100 text-stone-600")
s))
(list "text-sm" "text-lg" "text-2xl" "text-4xl"))))
(div
(label (~tw :tokens "text-xs text-stone-400 block mb-1") "Weight")
(div
(~tw :tokens "flex gap-1")
(map
(fn
(w)
(button
:on-click (fn (e) (reset! weight w))
(~tw :tokens "px-2 py-1 rounded text-xs")
:class (if
(= (deref weight) w)
"bg-stone-700 text-white"
"bg-stone-100 text-stone-600")
w))
(list "font-normal" "font-bold" "font-semibold"))))
(div
(label (~tw :tokens "text-xs text-stone-400 block mb-1") "Text")
(input
:type "text"
:bind text
(~tw
:tokens "w-full px-2 py-1 rounded border border-stone-300 text-sm"))))
(div
(~tw :tokens "flex gap-2 items-center")
(button
:on-click (fn
(e)
(let
((sx (freeze-to-sx "widget")))
(swap! saved (fn (l) (append l (list sx))))))
(~tw
:tokens "px-3 py-1.5 rounded bg-amber-500 text-white text-sm hover:bg-amber-600")
"Save config")
(span
(~tw :tokens "text-xs text-stone-400")
(str (len (deref saved)) " saved")))
(when
(not (empty? (deref saved)))
(div
(~tw :tokens "space-y-1")
(label (~tw :tokens "text-xs text-stone-400 block") "Saved configs")
(map-indexed
(fn
(i sx)
(button
:on-click (fn (e) (thaw-from-sx sx))
(~tw
:tokens "block w-full text-left px-2 py-1 rounded bg-stone-50 hover:bg-stone-100 font-mono text-xs text-stone-600 truncate")
(str "Config " (+ i 1))))
(deref saved)))))))

View File

@@ -0,0 +1,82 @@
(defcomp
()
(~docs/page
:title "Content-Addressed Computation"
(p
(~tw :tokens "text-stone-500 text-sm italic mb-8")
"A computation is a value. A value has a hash. The hash is the address. "
"Same state, same address, forever.")
(~docs/section
:title "The idea"
:id "idea"
(p
"Freeze a scope to SX. Hash the SX text. The hash is a content identifier (CID). "
"Store the frozen SX keyed by CID. Later, look up the CID, thaw, resume.")
(p
"The critical property: "
(strong "same state always produces the same CID")
". "
"Two machines freezing identical signal values get identical CIDs. "
"The address IS the content."))
(~docs/section
:title "How it works"
:id "how"
(~docs/code
:src (highlight
";; Freeze a scope → hash → CID\n(freeze-to-cid \"widget\")\n;; => \"d9eea67b\"\n\n;; The frozen SX is stored by CID\n(content-get \"d9eea67b\")\n;; => {:name \"widget\" :signals {:count 42 :name \"hello\"}}\n\n;; Thaw from CID → signals restored\n(thaw-from-cid \"d9eea67b\")\n;; Signals reset to frozen values"
"lisp"))
(p
"The hash is djb2 for now — deterministic and fast. "
"Real deployment uses SHA-256 / multihash for IPFS compatibility."))
(~docs/section
:title "Why it matters"
:id "why"
(ul
(~tw :tokens "list-disc pl-6 mb-4 space-y-2 text-stone-600")
(li
(strong "Sharing")
" — send a CID, not a blob. "
"The receiver looks up the CID and gets the exact state.")
(li
(strong "Deduplication")
" — same state = same CID. "
"Store once, reference many times.")
(li
(strong "Verification")
" — re-freeze, compare CIDs. "
"If they match, the state is identical. No diffing needed.")
(li
(strong "History")
" — each CID is a snapshot. "
"A sequence of CIDs is a complete undo history.")
(li
(strong "Distribution")
" — CIDs on IPFS are global. "
"Pin a widget state, anyone can thaw it. "
"No server, no API, no account.")))
(~docs/section
:title "Live demo"
:id "demo"
(p
"Change the counter and name. Click "
(strong "Content-address")
" to freeze and hash. "
"The CID appears below. Change the values, then click any CID in the history "
"or paste one into the input to restore.")
(~geography/cek/content-address-demo))
(~docs/section
:title "The path to IPFS"
:id "ipfs"
(p "The content store is currently in-memory. The next steps:")
(ul
(~tw :tokens "list-disc pl-6 mb-4 space-y-1 text-stone-600")
(li "Replace djb2 with SHA-256 (browser SubtleCrypto)")
(li "Wrap in multihash + CIDv1 format")
(li "Store to IPFS via the Art DAG L2 registry")
(li "Pin CIDs attributed to " (code "sx-web.org"))
(li "Anyone can pin, fork, extend"))
(p
"The frozen SX is the content. The CID is the address. "
"IPFS is the network. The widget state becomes a permanent, "
"verifiable, shareable artifact — not trapped in a database, "
"not behind an API, not owned by anyone."))))

View File

@@ -0,0 +1,101 @@
(defcomp
()
(~docs/page
:title "CEK Demo"
(~docs/section
:title "What this demonstrates"
:id "what"
(p
"These are "
(strong "live islands")
" evaluated by the CEK machine. Every "
(code "eval-expr")
" goes through "
(code "cek-run")
". Every "
(code "(deref sig)")
" in an island creates a reactive DOM binding via continuation frames.")
(p
"The CEK machine is defined in "
(code "cek.sx")
" and "
(code "frames.sx")
" — pure s-expressions, bootstrapped to both JavaScript and Python."))
(~docs/section
:title "Stepper"
:id "stepper"
(p
"The CEK machine is pure data→data. Each step takes a state dict and returns a new one. "
"Type an expression, click Step to advance one CEK transition.")
(~geography/cek/demo-stepper
:initial-expr "(let ((x 10)) (+ x (* 2 3)))")
(~docs/code
:src (highlight (component-source "~geography/cek/demo-stepper") "lisp")))
(~docs/section
:title "Render stepper"
:id "render-stepper"
(p
"Watch a component render itself. The CEK evaluates the expression — "
"when it encounters "
(code "(div ...)")
", the render adapter produces HTML in one step. "
"Click Run to see the rendered output appear in the preview.")
(~geography/cek/demo-render-stepper)
(~docs/code
:src (highlight
(component-source "~geography/cek/demo-render-stepper")
"lisp")))
(~docs/section
:title "1. Counter"
:id "demo-counter"
(p
(code "(deref count)")
" in text position creates a reactive text node. "
(code "(deref doubled)")
" is a computed that updates when count changes.")
(~geography/cek/demo-counter :initial 0)
(~docs/code
:src (highlight (component-source "~geography/cek/demo-counter") "lisp")))
(~docs/section
:title "2. Computed chain"
:id "demo-chain"
(p
"Three levels of computed: base -> doubled -> quadrupled. Change base, all propagate.")
(~geography/cek/demo-chain)
(~docs/code
:src (highlight (component-source "~geography/cek/demo-chain") "lisp")))
(~docs/section
:title "3. Reactive attributes"
:id "demo-attr"
(p
(code "(deref sig)")
" in "
(code ":class")
" position. The CEK evaluates the "
(code "str")
" expression, and when the signal changes, the continuation re-evaluates and updates the attribute.")
(~geography/cek/demo-reactive-attr)
(~docs/code
:src (highlight
(component-source "~geography/cek/demo-reactive-attr")
"lisp")))
(~docs/section
:title "4. Effect + cleanup"
:id "demo-stopwatch"
(p
"Effects still work through CEK. This stopwatch uses "
(code "effect")
" with cleanup — toggling the signal clears the interval.")
(~geography/cek/demo-stopwatch)
(~docs/code
:src (highlight (component-source "~geography/cek/demo-stopwatch") "lisp")))
(~docs/section
:title "5. Batch coalescing"
:id "demo-batch"
(p
"Two signals updated in "
(code "batch")
" — one notification cycle. Compare render counts between batch and no-batch.")
(~geography/cek/demo-batch)
(~docs/code
:src (highlight (component-source "~geography/cek/demo-batch") "lisp")))))

View File

@@ -0,0 +1,116 @@
(defcomp
()
(~docs/page
:title "Freeze / Thaw"
(p
(~tw :tokens "text-stone-500 text-sm italic mb-8")
"A computation is a value. Freeze it to an s-expression. "
"Store it, transmit it, content-address it. Thaw and resume anywhere.")
(~docs/section
:title "The idea"
:id "idea"
(p
"The CEK machine makes evaluation explicit: every step is a pure function from state to state. "
"The state is a dict with four fields:")
(ul
(~tw :tokens "list-disc pl-6 mb-4 space-y-1 text-stone-600")
(li (code "control") " — the expression being evaluated")
(li (code "env") " — the bindings in scope")
(li
(code "kont")
" — the continuation (what to do with the result)")
(li (code "phase") " — eval or continue"))
(p
"Since the state is data, it can be serialized. "
(code "cek-freeze")
" converts a live CEK state to pure s-expressions. "
(code "cek-thaw")
" reconstructs a live state from frozen SX. "
(code "cek-run")
" resumes from where it left off."))
(~docs/section
:title "Freeze"
:id "freeze"
(p "Take a computation mid-flight and freeze it:")
(~docs/code
:src (highlight
"(let ((expr (sx-parse \"(+ 1 (* 2 3))\"))\n (state (make-cek-state (first expr) (make-env) (list))))\n ;; Step 4 times\n (set! state (cek-step (cek-step (cek-step (cek-step state)))))\n ;; Freeze to SX\n (cek-freeze state))"
"lisp"))
(p "The frozen state is pure SX:")
(~docs/code
:src (highlight
"{:phase \"continue\"\n :control nil\n :value 1\n :env {}\n :kont ({:type \"arg\"\n :f (primitive \"+\")\n :evaled ()\n :remaining ((* 2 3))\n :env {}})}"
"lisp"))
(p
"Everything is data. The continuation frame says: “I was adding 1 to something, "
"and I still need to evaluate "
(code "(* 2 3)")
".”"))
(~docs/section
:title "Thaw and resume"
:id "thaw"
(p "Parse the frozen SX back. Thaw it. Resume:")
(~docs/code
:src (highlight
"(let ((frozen (sx-parse frozen-text))\n (state (cek-thaw (first frozen))))\n (cek-run state))\n;; => 7"
"lisp"))
(p
"Native functions like "
(code "+")
" serialize as "
(code "(primitive \"+\")")
" and are looked up in the primitive registry on thaw. "
"Lambdas serialize as their source AST — "
(code "(lambda (x) (* x 2))")
" — and reconstruct as callable functions."))
(~docs/section
:title "Live demo"
:id "demo"
(p
"Type an expression, step to any point, freeze the state. "
"The frozen SX appears below. Click Thaw to resume from the frozen state.")
(~geography/cek/freeze-demo))
(~docs/section
:title "Content addressing"
:id "content-addressing"
(p
"Hash the frozen SX "
(code "→")
" content identifier. "
"Same state always produces the same CID. Store by CID, retrieve by CID, verify by CID.")
(~docs/code
:src (highlight
"(freeze-to-cid \"widget\")\n;; => \"d9eea67b\"\n\n(thaw-from-cid \"d9eea67b\")\n;; Signals restored. Same CID = same state."
"lisp"))
(p
"Try it: change the values, click Content-address. Copy the CID. "
"Change the values again. Paste the CID and Restore.")
(~geography/cek/content-address-demo))
(~docs/section
:title "What this enables"
:id "enables"
(ul
(~tw :tokens "list-disc pl-6 mb-4 space-y-2 text-stone-600")
(li
(strong "Persistence")
" — save reactive island state to localStorage, "
"resume on page reload")
(li
(strong "Migration")
" — freeze a computation on one machine, "
"thaw on another. Same result, deterministically.")
(li
(strong "Content addressing")
" — hash the frozen SX → CID. "
"A pointer to a computation in progress, not just a value.")
(li
(strong "Time travel")
" — freeze at each step, store the history. "
"Jump to any point. Undo. Branch.")
(li
(strong "Verification")
" — re-run from a frozen state, "
"check the result matches. Reproducible computation."))
(p
"The Platonic argument made concrete: a computation IS a value. "
"The Form persists. The instance resumes."))))

View File

@@ -0,0 +1,94 @@
(defcomp
()
(~docs/page
:title "CEK Machine"
(~docs/section
:title "Three registers"
:id "registers"
(p
"The CEK machine makes evaluation explicit. Every step is a pure function from state to state:")
(ul
(~tw :tokens "space-y-1 text-stone-600 list-disc pl-5")
(li (strong "C") "ontrol — the expression being evaluated")
(li (strong "E") "nvironment — the bindings in scope")
(li (strong "K") "ontinuation — what to do with the result"))
(p
"The tree-walk evaluator uses the same three things, but hides them in the call stack. The CEK makes them "
(em "data")
" — inspectable, serializable, capturable."))
(~docs/section
:title "Why it matters"
:id "why"
(p "Making the continuation explicit enables:")
(ul
(~tw :tokens "space-y-1 text-stone-600 list-disc pl-5")
(li
(strong "Stepping")
" — pause evaluation, inspect state, resume")
(li
(strong "Serialization")
" — save a computation mid-flight, restore later")
(li
(strong "Delimited continuations")
" — "
(code "shift")
"/"
(code "reset")
" capture \"the rest of this expression\" as a value")
(li
(strong "Deref-as-shift")
" — "
(code "(deref sig)")
" inside a reactive boundary captures the continuation as the subscriber")))
(~docs/section
:title "Default evaluator"
:id "default"
(p
"CEK is the default evaluator on both client (JS) and server (Python). Every "
(code "eval-expr")
" call goes through "
(code "cek-run")
". The tree-walk evaluator is preserved as "
(code "_tree_walk_eval_expr")
" for test runners that interpret "
(code ".sx")
" files.")
(p "The CEK is defined in two spec files:")
(ul
(~tw :tokens "space-y-1 text-stone-600 list-disc pl-5")
(li
(code "frames.sx")
" — frame types (IfFrame, ArgFrame, ResetFrame, ReactiveResetFrame, ...)")
(li
(code "cek.sx")
" — step function, run loop, special form handlers, continuation operations")))
(~docs/section
:title "Deref as shift"
:id "deref-as-shift"
(p
"The reactive payoff. When "
(code "(deref sig)")
" encounters a signal inside a "
(code "reactive-reset")
" boundary:")
(ol
(~tw :tokens "space-y-1 text-stone-600 list-decimal pl-5")
(li
(strong "Shift")
" — capture all frames between here and the reactive-reset")
(li
(strong "Subscribe")
" — register the captured continuation as a signal subscriber")
(li
(strong "Return")
" — flow the current signal value through the rest of the expression"))
(p
"When the signal changes, the captured continuation is re-invoked with the new value. The "
(code "update-fn")
" on the ReactiveResetFrame mutates the DOM. No explicit "
(code "effect()")
" wrapping needed.")
(~docs/code
:src (highlight
";; User writes:\n(div :class (str \"count-\" (deref counter))\n (str \"Value: \" (deref counter)))\n\n;; CEK sees (deref counter) → signal? → reactive-reset on stack?\n;; Yes: capture (str \"count-\" [HOLE]) as continuation\n;; Register as subscriber. Return current value.\n;; When counter changes: re-invoke continuation → update DOM."
"lisp")))))

View File

@@ -1,4 +1,4 @@
(defcomp ~geography/eval-rules-content ()
(defcomp ()
(~docs/page :title "Evaluation Rules"
(p (~tw :tokens "text-stone-500 text-sm italic mb-8")
"Machine-readable SX semantics — a non-circular reference for how the language evaluates expressions.")

View File

@@ -0,0 +1,50 @@
(defcomp
(&key
(title :as string)
(description :as string)
(demo-description :as string?)
demo
(sx-code :as string)
(sx-lang :as string?)
(handler-names :as list)
(handler-lang :as string?)
(comp-placeholder-id :as string?)
(wire-placeholder-id :as string?)
(wire-note :as string?)
(comp-heading :as string?)
(handler-heading :as string?))
(~docs/page
:title title
(p (~tw :tokens "text-stone-600 mb-6") description)
(error-boundary
(~examples/card
:title "Demo"
:description demo-description
(~examples/demo demo)))
(h3 (~tw :tokens "text-lg font-semibold text-stone-700 mt-8 mb-3") "S-expression")
(error-boundary
(~examples/source
:src-code (highlight sx-code (if sx-lang sx-lang "lisp"))))
(when
comp-placeholder-id
(<>
(h3
(~tw :tokens "text-lg font-semibold text-stone-700 mt-8 mb-3")
(if comp-heading comp-heading "Component"))
(error-boundary (~docs/placeholder :id comp-placeholder-id))))
(h3
(~tw :tokens "text-lg font-semibold text-stone-700 mt-8 mb-3")
(if handler-heading handler-heading "Server handler"))
(error-boundary
(~examples/source
:src-code (highlight
(join "\n\n" (map handler-source handler-names))
(if handler-lang handler-lang "sx"))))
(div
(~tw :tokens "flex items-center justify-between mt-6")
(h3 (~tw :tokens "text-lg font-semibold text-stone-700") "Wire response")
(~docs/clear-cache-btn))
(when wire-note (p (~tw :tokens "text-stone-500 text-sm mb-2") wire-note))
(when
wire-placeholder-id
(error-boundary (~docs/placeholder :id wire-placeholder-id)))))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Active Search"
:description "An input with sx-trigger=\"keyup delay:300ms changed\" debounces keystrokes and only fires when the value changes. The server filters a list of programming languages."
:demo-description "Type to search through 20 programming languages."
:demo (~examples/active-search-demo)
:sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.search))))\"\n :sx-trigger \"keyup delay:300ms changed\"\n :sx-target \"#search-results\"\n :sx-swap \"innerHTML\"\n :placeholder \"Search...\")"
:handler-names (list "ex-search")
:comp-placeholder-id "search-comp"
:wire-placeholder-id "search-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Animations"
:description "CSS animations play on swap. The component injects a style tag with a keyframe animation and applies the class. Each click picks a random background colour."
:demo-description "Click to swap in content with a fade-in animation."
:demo (~examples/animations-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.animate))))\"\n :sx-target \"#anim-target\"\n :sx-swap \"innerHTML\"\n \"Load with animation\")\n\n;; Component uses CSS animation class\n(defcomp ~examples-content/anim-result (&key color time)\n (div :class \"sx-fade-in ...\"\n (style \".sx-fade-in { animation: sxFadeIn 0.5s }\")\n (p \"Faded in!\")))"
:handler-names (list "ex-animate")
:comp-placeholder-id "anim-comp"
:wire-placeholder-id "anim-wire"))

View File

@@ -0,0 +1,17 @@
(defcomp
()
(~examples/page-content
:title "Bulk Update"
:description "Select rows with checkboxes and use Activate/Deactivate buttons. sx-include gathers checkbox values from the form."
:demo-description "Check some rows, then click Activate or Deactivate."
:demo (~examples/bulk-update-demo
:users (list
(list "1" "Alice Chen" "alice@example.com" "active")
(list "2" "Bob Rivera" "bob@example.com" "inactive")
(list "3" "Carol Zhang" "carol@example.com" "active")
(list "4" "Dan Okafor" "dan@example.com" "inactive")
(list "5" "Eve Larsson" "eve@example.com" "active")))
:sx-code "(button\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.bulk))))?action=activate\"\n :sx-target \"#bulk-table\"\n :sx-swap \"innerHTML\"\n :sx-include \"#bulk-form\"\n \"Activate\")"
:handler-names (list "ex-bulk")
:comp-placeholder-id "bulk-comp"
:wire-placeholder-id "bulk-wire"))

View File

@@ -0,0 +1,12 @@
(defcomp
()
(~examples/page-content
:title "Click to Load"
:description "The simplest sx interaction: click a button, fetch content from the server, swap it in."
:demo-description "Click the button to load server-rendered content."
:demo (~examples/click-to-load-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.click))))\"\n :sx-target \"#click-result\"\n :sx-swap \"innerHTML\"\n \"Load content\")"
:handler-names (list "ex-click")
:comp-placeholder-id "click-comp"
:wire-placeholder-id "click-wire"
:wire-note "The server responds with content-type text/sx. New CSS rules are prepended as a style tag. Clear the component cache to see component definitions included in the wire response."))

View File

@@ -0,0 +1,18 @@
(defcomp
()
(~examples/page-content
:title "Delete Row"
:description "sx-delete with sx-swap \"outerHTML\" and an empty response removes the row from the DOM."
:demo-description "Click delete to remove a row. Uses sx-confirm for confirmation."
:demo (~examples/delete-demo
:items (list
(list "1" "Implement dark mode")
(list "2" "Fix login bug")
(list "3" "Write documentation")
(list "4" "Deploy to production")
(list "5" "Add unit tests")))
:sx-code "(button\n :sx-delete \"/sx/(geography.(hypermedia.(example.(api.(delete.1)))))\"\n :sx-target \"#row-1\"\n :sx-swap \"outerHTML\"\n :sx-confirm \"Delete this item?\"\n \"delete\")"
:handler-names (list "ex-delete")
:comp-placeholder-id "delete-comp"
:wire-placeholder-id "delete-wire"
:wire-note "Empty body — outerHTML swap replaces the target element with nothing."))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Dialogs"
:description "Open a modal dialog by swapping in the dialog component. Close by swapping in empty content. Pure sx — no JavaScript library needed."
:demo-description "Click to open a modal dialog."
:demo (~examples/dialogs-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dialog))))\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Open Dialog\")\n\n;; Dialog closes by swapping empty content\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dialog-close))))\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Close\")"
:handler-names (list "ex-dialog" "ex-dialog-close")
:comp-placeholder-id "dialog-comp"
:wire-placeholder-id "dialog-wire"))

View File

@@ -0,0 +1,16 @@
(defcomp
()
(~examples/page-content
:title "Edit Row"
:description "Click edit to replace a table row with input fields. Save or cancel swaps back the display row. Uses sx-include to gather form values from the row."
:demo-description "Click edit on any row to modify it inline."
:demo (~examples/edit-row-demo
:rows (list
(list "1" "Widget A" "19.99" "142")
(list "2" "Widget B" "24.50" "89")
(list "3" "Widget C" "12.00" "305")
(list "4" "Widget D" "45.00" "67")))
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.(editrow.1)))))\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n \"edit\")\n\n;; Save sends form data via POST\n(button\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.(editrow.1)))))\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n :sx-include \"#erow-1\"\n \"save\")"
:handler-names (list "ex-editrow-form" "ex-editrow-save")
:comp-placeholder-id "editrow-comp"
:wire-placeholder-id "editrow-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Form Submission"
:description "Forms with sx-post submit via AJAX and swap the response into a target."
:demo-description "Enter a name and submit."
:demo (~examples/form-demo)
:sx-code "(form\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.form))))\"\n :sx-target \"#form-result\"\n :sx-swap \"innerHTML\"\n (input :type \"text\" :name \"name\")\n (button :type \"submit\" \"Submit\"))"
:handler-names (list "ex-form")
:comp-placeholder-id "form-comp"
:wire-placeholder-id "form-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Infinite Scroll"
:description "A sentinel element at the bottom uses sx-trigger=\"intersect once\" to load the next page when scrolled into view. Each response appends items and a new sentinel."
:demo-description "Scroll down in the container to load more items (5 pages total)."
:demo (~examples/infinite-scroll-demo)
:sx-code "(div :id \"scroll-sentinel\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.scroll))))?page=2\"\n :sx-trigger \"intersect once\"\n :sx-target \"#scroll-items\"\n :sx-swap \"beforeend\"\n \"Loading more...\")"
:handler-names (list "ex-scroll")
:comp-placeholder-id "scroll-comp"
:wire-placeholder-id "scroll-wire"))

View File

@@ -0,0 +1,13 @@
(defcomp
()
(~examples/page-content
:title "Inline Edit"
:description "Click edit to swap a display view for an edit form. Save swaps back."
:demo-description "Click edit, modify the text, save or cancel."
:demo (~examples/inline-edit-demo)
:sx-code ";; View mode — shows text + edit button\n(~examples/inline-view :value \"some text\")\n\n;; Edit mode — returned by server on click\n(~examples/inline-edit-form :value \"some text\")"
:handler-names (list "ex-edit-form" "ex-edit-save")
:comp-placeholder-id "edit-comp"
:comp-heading "Components"
:handler-heading "Server handlers"
:wire-placeholder-id "edit-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Inline Validation"
:description "Validate an email field on blur. The server checks format and whether it is taken, returning green or red feedback inline."
:demo-description "Enter an email and click away (blur) to validate."
:demo (~examples/inline-validation-demo)
:sx-code "(input :type \"text\" :name \"email\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.validate))))\"\n :sx-trigger \"blur\"\n :sx-target \"#email-feedback\"\n :sx-swap \"innerHTML\"\n :placeholder \"user@example.com\")"
:handler-names (list "ex-validate")
:comp-placeholder-id "validate-comp"
:wire-placeholder-id "validate-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "JSON Encoding"
:description "Use sx-encoding=\"json\" to send form data as a JSON body instead of URL-encoded form data. The server echoes back what it received."
:demo-description "Submit the form and see the JSON body the server received."
:demo (~examples/json-encoding-demo)
:sx-code "(form\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.json-echo))))\"\n :sx-target \"#json-result\"\n :sx-swap \"innerHTML\"\n :sx-encoding \"json\"\n (input :name \"name\" :value \"Ada\")\n (input :type \"number\" :name \"age\" :value \"36\")\n (button \"Submit as JSON\"))"
:handler-names (list "ex-json-echo")
:comp-placeholder-id "json-comp"
:wire-placeholder-id "json-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Keyboard Shortcuts"
:description "Use sx-trigger with keyup event filters and from:body to listen for global keyboard shortcuts. The filter prevents firing when typing in inputs."
:demo-description "Press s, n, or h on your keyboard."
:demo (~examples/keyboard-shortcuts-demo)
:sx-code "(div :id \"kbd-target\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.keyboard))))?key=s\"\n :sx-trigger \"keyup[key=='s'&&!event.target.matches('input,textarea')] from:body\"\n :sx-swap \"innerHTML\"\n \"Press a shortcut key...\")"
:handler-names (list "ex-keyboard")
:comp-placeholder-id "kbd-comp"
:wire-placeholder-id "kbd-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Lazy Loading"
:description "Use sx-trigger=\"load\" to fetch content as soon as the element enters the DOM. Great for deferring expensive content below the fold."
:demo-description "Content loads automatically when the page renders."
:demo (~examples/lazy-loading-demo)
:sx-code "(div\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.lazy))))\"\n :sx-trigger \"load\"\n :sx-swap \"innerHTML\"\n (div :class \"animate-pulse\" \"Loading...\"))"
:handler-names (list "ex-lazy")
:comp-placeholder-id "lazy-comp"
:wire-placeholder-id "lazy-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Loading States"
:description "sx.js adds the .sx-request CSS class to any element that has an active request. Use pure CSS to show spinners, disable buttons, or change opacity during loading."
:demo-description "Click the button — it shows a spinner during the 2-second request."
:demo (~examples/loading-states-demo)
:sx-code ";; .sx-request class added during request\n(style \".sx-loading-btn.sx-request {\n opacity: 0.7; pointer-events: none; }\n.sx-loading-btn.sx-request .sx-spinner {\n display: inline-block; }\n.sx-loading-btn .sx-spinner {\n display: none; }\")\n\n(button :class \"sx-loading-btn\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.slow))))\"\n :sx-target \"#loading-result\"\n (span :class \"sx-spinner animate-spin\" \"...\")\n \"Load slow endpoint\")"
:handler-names (list "ex-slow")
:comp-placeholder-id "loading-comp"
:wire-placeholder-id "loading-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Out-of-Band Swaps"
:description "sx-swap-oob lets a single response update multiple elements anywhere in the DOM."
:demo-description "One request updates both Box A (via sx-target) and Box B (via sx-swap-oob)."
:demo (~examples/oob-demo)
:sx-code ";; Button targets Box A\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.oob))))\"\n :sx-target \"#oob-box-a\"\n :sx-swap \"innerHTML\"\n \"Update both boxes\")"
:handler-names (list "ex-oob")
:wire-placeholder-id "oob-wire"
:wire-note "The fragment contains both the main content and an OOB element. sx.js splits them: main content goes to sx-target, OOB elements find their targets by ID."))

View File

@@ -0,0 +1,12 @@
(defcomp
()
(~examples/page-content
:title "Polling"
:description "Use sx-trigger with \"every\" to poll the server at regular intervals."
:demo-description "This div polls the server every 2 seconds."
:demo (~examples/polling-demo)
:sx-code "(div\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.poll))))\"\n :sx-trigger \"load, every 2s\"\n :sx-swap \"innerHTML\"\n \"Loading...\")"
:handler-names (list "ex-poll")
:comp-placeholder-id "poll-comp"
:wire-placeholder-id "poll-wire"
:wire-note "Updates every 2 seconds — watch the time and count change."))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Progress Bar"
:description "Start a server-side job, then poll for progress using sx-trigger=\"load delay:500ms\" on each response. The bar fills up and stops when complete."
:demo-description "Click start to begin a simulated job."
:demo (~examples/progress-bar-demo)
:sx-code ";; Start the job\n(button\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.progress-start))))\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")\n\n;; Each response re-polls via sx-trigger=\"load\"\n(div :sx-get \"/sx/(geography.(hypermedia.(example.(api.progress-status))))?job=ID\"\n :sx-trigger \"load delay:500ms\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")"
:handler-names (list "ex-progress-start" "ex-progress-status")
:comp-placeholder-id "progress-comp"
:wire-placeholder-id "progress-wire"))

View File

@@ -0,0 +1,14 @@
(defcomp
()
(~examples/page-content
:title "PUT / PATCH"
:description "sx-put replaces the entire resource. This example shows a profile card with an Edit All button that sends a PUT with all fields."
:demo-description "Click Edit All to replace the full profile via PUT."
:demo (~examples/put-patch-demo
:name "Ada Lovelace"
:email "ada@example.com"
:role "Engineer")
:sx-code ";; Replace entire resource\n(form :sx-put \"/sx/(geography.(hypermedia.(example.(api.putpatch))))\"\n :sx-target \"#pp-target\" :sx-swap \"innerHTML\"\n (input :name \"name\") (input :name \"email\")\n (button \"Save All (PUT)\"))"
:handler-names (list "ex-pp-edit-all" "ex-pp-put")
:comp-placeholder-id "pp-comp"
:wire-placeholder-id "pp-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Reset on Submit"
:description "Use sx-on:afterSwap=\"this.reset()\" to clear form inputs after a successful submission."
:demo-description "Submit a message — the input resets after each send."
:demo (~examples/reset-on-submit-demo)
:sx-code "(form :id \"reset-form\"\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.reset-submit))))\"\n :sx-target \"#reset-result\"\n :sx-swap \"innerHTML\"\n :sx-on:afterSwap \"this.reset()\"\n (input :type \"text\" :name \"message\")\n (button :type \"submit\" \"Send\"))"
:handler-names (list "ex-reset-submit")
:comp-placeholder-id "reset-comp"
:wire-placeholder-id "reset-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Retry"
:description "sx-retry=\"exponential:1000:8000\" retries failed requests with exponential backoff starting at 1s up to 8s. The endpoint fails the first 2 attempts and succeeds on the 3rd."
:demo-description "Click the button — watch it retry automatically after failures."
:demo (~examples/retry-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.flaky))))\"\n :sx-target \"#retry-result\"\n :sx-swap \"innerHTML\"\n :sx-retry \"exponential:1000:8000\"\n \"Call flaky endpoint\")"
:handler-names (list "ex-flaky")
:comp-placeholder-id "retry-comp"
:wire-placeholder-id "retry-wire"))

View File

@@ -0,0 +1,10 @@
(defcomp
()
(~examples/page-content
:title "Select Filter"
:description "sx-select lets the client pick a specific section from the server response by CSS selector. The server always returns the full dashboard — the client filters."
:demo-description "Different buttons select different parts of the same server response."
:demo (~examples/select-filter-demo)
:sx-code ";; Pick just the stats section from the response\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dashboard))))\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n :sx-select \"#dash-stats\"\n \"Stats Only\")\n\n;; No sx-select — get the full response\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dashboard))))\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n \"Full Dashboard\")"
:handler-names (list "ex-dashboard")
:wire-placeholder-id "filter-wire"))

View File

@@ -0,0 +1,10 @@
(defcomp
()
(~examples/page-content
:title "Swap Positions"
:description "Demonstrates different swap modes: beforeend appends, afterbegin prepends, and none skips the main swap while still processing OOB updates."
:demo-description "Try each button to see different swap behaviours."
:demo (~examples/swap-positions-demo)
:sx-code ";; Append to end\n(button :sx-post \"/sx/(geography.(hypermedia.(example.(api.swap-log))))?mode=beforeend\"\n :sx-target \"#swap-log\" :sx-swap \"beforeend\"\n \"Add to End\")\n\n;; Prepend to start\n(button :sx-post \"/sx/(geography.(hypermedia.(example.(api.swap-log))))?mode=afterbegin\"\n :sx-target \"#swap-log\" :sx-swap \"afterbegin\"\n \"Add to Start\")\n\n;; No swap — OOB counter update only\n(button :sx-post \"/sx/(geography.(hypermedia.(example.(api.swap-log))))?mode=none\"\n :sx-target \"#swap-log\" :sx-swap \"none\"\n \"Silent Ping\")"
:handler-names (list "ex-swap-log")
:wire-placeholder-id "swap-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Request Abort"
:description "sx-sync=\"replace\" aborts any in-flight request before sending a new one. This prevents stale responses from overwriting newer ones, even with random server delays."
:demo-description "Type quickly — only the latest result appears despite random 0.5-2s server delays."
:demo (~examples/sync-replace-demo)
:sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.slow-search))))\"\n :sx-trigger \"keyup delay:200ms changed\"\n :sx-target \"#sync-result\"\n :sx-swap \"innerHTML\"\n :sx-sync \"replace\"\n \"Type to search...\")"
:handler-names (list "ex-slow-search")
:comp-placeholder-id "sync-comp"
:wire-placeholder-id "sync-wire"))

View File

@@ -0,0 +1,10 @@
(defcomp
()
(~examples/page-content
:title "Tabs"
:description "Tab navigation using sx-push-url to update the browser URL. Back/forward buttons navigate between previously visited tabs."
:demo-description "Click tabs to switch content. Watch the browser URL change."
:demo (~examples/tabs-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.(tabs.tab1)))))\"\n :sx-target \"#tab-content\"\n :sx-swap \"innerHTML\"\n :sx-push-url \"/sx/(geography.(hypermedia.(example.tabs)))?tab=tab1\"\n \"Overview\")"
:handler-names (list "ex-tabs")
:wire-placeholder-id "tabs-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Vals & Headers"
:description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received."
:demo-description "Click each button to see what the server receives."
:demo (~examples/vals-headers-demo)
:sx-code ";; Send extra values with the request\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.echo-vals))))\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.echo-headers))))\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")"
:handler-names (list "ex-echo-vals" "ex-echo-headers")
:comp-placeholder-id "vals-comp"
:wire-placeholder-id "vals-wire"))

View File

@@ -0,0 +1,11 @@
(defcomp
()
(~examples/page-content
:title "Value Select"
:description "Two linked selects: pick a category and the second select updates with matching items via sx-get."
:demo-description "Select a category to populate the item dropdown."
:demo (~examples/value-select-demo)
:sx-code "(select :name \"category\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.values))))\"\n :sx-trigger \"change\"\n :sx-target \"#value-items\"\n :sx-swap \"innerHTML\"\n (option \"Languages\")\n (option \"Frameworks\")\n (option \"Databases\"))"
:handler-names (list "ex-values")
:comp-placeholder-id "values-comp"
:wire-placeholder-id "values-wire"))

View File

@@ -0,0 +1,29 @@
(defcomp
(&key
(title :as string)
(description :as string)
demo
(example-code :as string)
(handler-code :as string?)
(wire-placeholder-id :as string?))
(~docs/page
:title title
(p (~tw :tokens "text-stone-600 mb-6") description)
(when demo (~examples/card :title "Demo" (~examples/demo demo)))
(h3 (~tw :tokens "text-lg font-semibold text-stone-700 mt-6") "S-expression")
(~examples/source :src-code (highlight example-code "lisp"))
(when
handler-code
(<>
(h3
(~tw :tokens "text-lg font-semibold text-stone-700 mt-6")
"Server handler")
(~examples/source :src-code (highlight handler-code "lisp"))))
(when
wire-placeholder-id
(<>
(h3 (~tw :tokens "text-lg font-semibold text-stone-700 mt-6") "Wire response")
(p
(~tw :tokens "text-stone-500 text-sm mb-2")
"Trigger the demo to see the raw response the server sends.")
(~docs/placeholder :id wire-placeholder-id)))))

View File

@@ -0,0 +1,5 @@
(defcomp
(&key (slug :as string))
(~docs/page
:title "Not Found"
(p (~tw :tokens "text-stone-600") (str "No documentation found for \"" slug "\"."))))

View File

@@ -0,0 +1,11 @@
(defcomp
(&key title description example-code demo)
(~docs/page
:title title
(p (~tw :tokens "text-stone-600 mb-6") description)
(when demo (~examples/card :title "Demo" (~examples/demo demo)))
(when
example-code
(<>
(h3 (~tw :tokens "text-lg font-semibold text-stone-700 mt-6") "Example usage")
(~examples/source :src-code (highlight example-code "lisp"))))))

View File

@@ -0,0 +1,31 @@
(defcomp
(&key
(title :as string)
(direction :as string)
(description :as string)
(example-code :as string?)
demo)
(~docs/page
:title title
(let
((badge-class (if (= direction "request") "bg-blue-100 text-blue-700" (if (= direction "response") "bg-emerald-100 text-emerald-700" "bg-amber-100 text-amber-700")))
(badge-label
(if
(= direction "request")
"Request Header"
(if
(= direction "response")
"Response Header"
"Request & Response"))))
(div
(~tw :tokens "flex items-center gap-3 mb-4")
(span
:class (str "text-xs font-medium px-2 py-1 rounded " badge-class)
badge-label)))
(p (~tw :tokens "text-stone-600 mb-6") description)
(when demo (~examples/card :title "Demo" (~examples/demo demo)))
(when
example-code
(<>
(h3 (~tw :tokens "text-lg font-semibold text-stone-700 mt-6") "Example usage")
(~examples/source :src-code (highlight example-code "lisp"))))))

View File

@@ -0,0 +1,8 @@
(defcomp
(&key req-table beh-table uniq-table)
(~docs/page
:title "Attribute Reference"
(p
(~tw :tokens "text-stone-600 mb-6")
"sx attributes mirror htmx where possible. This table shows all available attributes and their status.")
(div (~tw :tokens "space-y-8") req-table beh-table uniq-table)))

View File

@@ -0,0 +1,10 @@
(defcomp
(&key table)
(~docs/page
:title "Events"
(p
(~tw :tokens "text-stone-600 mb-6")
"sx fires custom DOM events at various points in the request lifecycle. "
"Listen for them with sx-on:* attributes or addEventListener. "
"Client-side routing fires sx:clientRoute instead of request lifecycle events.")
table))

View File

@@ -0,0 +1,8 @@
(defcomp
(&key req-table resp-table)
(~docs/page
:title "Headers"
(p
(~tw :tokens "text-stone-600 mb-6")
"sx uses custom HTTP headers to coordinate between client and server.")
(div (~tw :tokens "space-y-8") req-table resp-table)))

View File

@@ -0,0 +1,57 @@
(defcomp
()
(~docs/page
:title "Reference"
(p
(~tw :tokens "text-stone-600 mb-6")
"Complete reference for the sx client library.")
(div
(~tw :tokens "grid gap-4 sm:grid-cols-2")
(a
:href "/sx/(geography.(hypermedia.(reference.attributes)))"
:sx-get "/sx/(geography.(hypermedia.(reference.attributes)))"
:sx-target "#sx-content"
:sx-select "#sx-content"
:sx-swap "outerHTML"
:sx-push-url "true"
(~tw :tokens "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline")
(h3 (~tw :tokens "text-lg font-semibold text-violet-700 mb-1") "Attributes")
(p
(~tw :tokens "text-stone-600 text-sm")
"All sx attributes — request verbs, behavior modifiers, and sx-unique features."))
(a
:href "/sx/(geography.(hypermedia.(reference.headers)))"
:sx-get "/sx/(geography.(hypermedia.(reference.headers)))"
:sx-target "#sx-content"
:sx-select "#sx-content"
:sx-swap "outerHTML"
:sx-push-url "true"
(~tw :tokens "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline")
(h3 (~tw :tokens "text-lg font-semibold text-violet-700 mb-1") "Headers")
(p
(~tw :tokens "text-stone-600 text-sm")
"Custom HTTP headers used to coordinate between the sx client and server."))
(a
:href "/sx/(geography.(hypermedia.(reference.events)))"
:sx-get "/sx/(geography.(hypermedia.(reference.events)))"
:sx-target "#sx-content"
:sx-select "#sx-content"
:sx-swap "outerHTML"
:sx-push-url "true"
(~tw :tokens "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline")
(h3 (~tw :tokens "text-lg font-semibold text-violet-700 mb-1") "Events")
(p
(~tw :tokens "text-stone-600 text-sm")
"DOM events fired during the sx request lifecycle."))
(a
:href "/sx/(geography.(hypermedia.(reference.js-api)))"
:sx-get "/sx/(geography.(hypermedia.(reference.js-api)))"
:sx-target "#sx-content"
:sx-select "#sx-content"
:sx-swap "outerHTML"
:sx-push-url "true"
(~tw :tokens "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline")
(h3 (~tw :tokens "text-lg font-semibold text-violet-700 mb-1") "JS API")
(p
(~tw :tokens "text-stone-600 text-sm")
"JavaScript functions for parsing, evaluating, and rendering s-expressions.")))))

View File

@@ -0,0 +1,3 @@
(defcomp
(&key table)
(~docs/page :title "JavaScript API" table))

View File

@@ -1,7 +1,7 @@
;; Geography index — architecture overview
;; Describes the rendering pipeline: OCaml evaluator → wire formats → client
(defcomp ~geography/index-content () :affinity :server
(defcomp () :affinity :server
(div (~tw :tokens "max-w-4xl mx-auto px-6 pb-8 pt-4")
(h2 (~tw :tokens "text-3xl font-bold text-stone-800 mb-4") "Geography")
(p (~tw :tokens "text-lg text-stone-600 mb-8")

View File

@@ -0,0 +1,12 @@
;; Affinity demo — Phase 7a render boundary annotations.
;;
;; Demonstrates :affinity annotations on defcomp and how they influence
;; the server/client render boundary decision. Components declare where
;; they prefer to render; the runtime combines this with IO analysis.
;; --- Demo components with different affinities ---
(defcomp (&key (label :as string?))
(div (~tw :tokens "rounded border border-stone-200 bg-white p-4")
(div (~tw :tokens "flex items-center gap-2 mb-2")
(span (~tw :tokens "inline-block w-2 h-2 rounded-full bg-stone-400"))
(span (~tw :tokens "text-sm font-mono text-stone-500") ":affinity :auto"))
(p (~tw :tokens "text-stone-800") (or label "Pure component — no IO calls. Auto-detected as client-renderable."))))

View File

@@ -0,0 +1,7 @@
(defcomp (&key (label :as string?))
:affinity :client
(div (~tw :tokens "rounded border border-blue-200 bg-blue-50 p-4")
(div (~tw :tokens "flex items-center gap-2 mb-2")
(span (~tw :tokens "inline-block w-2 h-2 rounded-full bg-blue-400"))
(span (~tw :tokens "text-sm font-mono text-blue-600") ":affinity :client"))
(p (~tw :tokens "text-blue-800") (or label "Explicitly client-rendered — even IO calls would be proxied."))))

View File

@@ -0,0 +1,7 @@
(defcomp ()
(div (~tw :tokens "rounded border border-red-200 bg-red-50 p-4")
(div (~tw :tokens "flex items-center gap-2 mb-2")
(span (~tw :tokens "inline-block w-2 h-2 rounded-full bg-red-400"))
(span (~tw :tokens "text-sm font-mono text-red-600") ":affinity :auto + IO"))
(p (~tw :tokens "text-red-800 mb-3") "Auto affinity with IO dependency — auto-detected as server-rendered.")
(~docs/code :src (highlight "(render-target name env io-names)" "lisp"))))

View File

@@ -0,0 +1,8 @@
(defcomp ()
:affinity :client
(div (~tw :tokens "rounded border border-violet-200 bg-violet-50 p-4")
(div (~tw :tokens "flex items-center gap-2 mb-2")
(span (~tw :tokens "inline-block w-2 h-2 rounded-full bg-violet-400"))
(span (~tw :tokens "text-sm font-mono text-violet-600") ":affinity :client + IO"))
(p (~tw :tokens "text-violet-800 mb-3") "Client affinity overrides IO — calls proxied to server via /sx/io/.")
(~docs/code :src (highlight "(component-affinity comp)" "lisp"))))

View File

@@ -0,0 +1,7 @@
(defcomp (&key (label :as string?))
:affinity :server
(div (~tw :tokens "rounded border border-amber-200 bg-amber-50 p-4")
(div (~tw :tokens "flex items-center gap-2 mb-2")
(span (~tw :tokens "inline-block w-2 h-2 rounded-full bg-amber-400"))
(span (~tw :tokens "text-sm font-mono text-amber-600") ":affinity :server"))
(p (~tw :tokens "text-amber-800") (or label "Always server-rendered — auth-sensitive or secret-dependent."))))

View File

@@ -0,0 +1,156 @@
;; --- Main page component ---
(defcomp (&key components page-plans)
(div (~tw :tokens "space-y-8")
(div (~tw :tokens "border-b border-stone-200 pb-6")
(h1 (~tw :tokens "text-2xl font-bold text-stone-900") "Affinity Annotations")
(p (~tw :tokens "mt-2 text-stone-600")
"Phase 7a: components declare where they prefer to render. The "
(code (~tw :tokens "bg-stone-100 px-1 rounded text-violet-700") "render-target")
" function in deps.sx combines the annotation with IO analysis to produce a per-component boundary decision."))
;; Syntax
(~docs/section :title "Syntax" :id "syntax"
(p "Add " (code ":affinity") " between the params list and the body:")
(~docs/code :src (highlight "(defcomp ~affinity-demo/my-component (&key title)\n :affinity :client ;; or :server, or omit for :auto\n (div title))" "lisp"))
(p "Three values:")
(ul (~tw :tokens "list-disc pl-5 text-stone-700 space-y-1")
(li (code ":auto") " (default) — runtime decides from IO dependency analysis")
(li (code ":client") " — always render client-side; IO calls proxied to server")
(li (code ":server") " — always render server-side; never sent to client as SX")))
;; Live components
(~docs/section :title "Live Components" :id "live"
(p "These components are defined with different affinities. The server analyzed them at registration time:")
(div (~tw :tokens "space-y-4 mt-4")
(~affinity-demo/aff-demo-auto)
(~affinity-demo/aff-demo-client)
(~affinity-demo/aff-demo-server)
(~affinity-demo/aff-demo-io-auto)
(~affinity-demo/aff-demo-io-client)))
;; Analysis table from server
(~docs/section :title "Server Analysis" :id "analysis"
(p "The server computed these render targets at component registration time:")
(div (~tw :tokens "overflow-x-auto rounded border border-stone-200")
(table (~tw :tokens "w-full text-left text-sm")
(thead (tr (~tw :tokens "border-b border-stone-200 bg-stone-100")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Component")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Affinity")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "IO Deps")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Render Target")))
(tbody
(map (fn (c)
(tr (~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 font-mono text-sm text-violet-700") (get c "name"))
(td (~tw :tokens "px-3 py-2")
(span :class (str "inline-block px-2 py-0.5 rounded text-xs font-bold uppercase "
(case (get c "affinity")
"client" "bg-blue-100 text-blue-700"
"server" "bg-amber-100 text-amber-700"
"bg-stone-100 text-stone-600"))
(get c "affinity")))
(td (~tw :tokens "px-3 py-2 text-stone-600")
(if (> (len (get c "io-refs")) 0)
(span (~tw :tokens "text-red-600 font-medium")
(join ", " (get c "io-refs")))
(span (~tw :tokens "text-green-600") "none")))
(td (~tw :tokens "px-3 py-2")
(span :class (str "inline-block px-2 py-0.5 rounded text-xs font-bold uppercase "
(if (= (get c "render-target") "client")
"bg-green-100 text-green-700"
"bg-orange-100 text-orange-700"))
(get c "render-target")))))
components)))))
;; Decision matrix
(~docs/section :title "Decision Matrix" :id "matrix"
(div (~tw :tokens "overflow-x-auto rounded border border-stone-200")
(table (~tw :tokens "w-full text-left text-sm")
(thead (tr (~tw :tokens "border-b border-stone-200 bg-stone-100")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Affinity")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Has IO?")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Render Target")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Why")))
(tbody
(tr (~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 font-mono") ":auto")
(td (~tw :tokens "px-3 py-2") "No")
(td (~tw :tokens "px-3 py-2 font-bold text-green-700") "client")
(td (~tw :tokens "px-3 py-2 text-stone-600") "Pure — can render anywhere"))
(tr (~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 font-mono") ":auto")
(td (~tw :tokens "px-3 py-2") "Yes")
(td (~tw :tokens "px-3 py-2 font-bold text-orange-700") "server")
(td (~tw :tokens "px-3 py-2 text-stone-600") "IO must resolve server-side"))
(tr (~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 font-mono") ":client")
(td (~tw :tokens "px-3 py-2") "No")
(td (~tw :tokens "px-3 py-2 font-bold text-green-700") "client")
(td (~tw :tokens "px-3 py-2 text-stone-600") "Explicit + pure"))
(tr (~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 font-mono") ":client")
(td (~tw :tokens "px-3 py-2") "Yes")
(td (~tw :tokens "px-3 py-2 font-bold text-green-700") "client")
(td (~tw :tokens "px-3 py-2 text-stone-600") "Override — IO proxied via /sx/io/"))
(tr (~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 font-mono") ":server")
(td (~tw :tokens "px-3 py-2") "No")
(td (~tw :tokens "px-3 py-2 font-bold text-orange-700") "server")
(td (~tw :tokens "px-3 py-2 text-stone-600") "Override — auth-sensitive"))
(tr (~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 font-mono") ":server")
(td (~tw :tokens "px-3 py-2") "Yes")
(td (~tw :tokens "px-3 py-2 font-bold text-orange-700") "server")
(td (~tw :tokens "px-3 py-2 text-stone-600") "Both affinity and IO say server"))))))
;; Per-page render plans
(~docs/section :title "Page Render Plans" :id "plans"
(p "Phase 7b: render plans are pre-computed at registration time for each page. The plan maps every component needed by the page to its render target.")
(when (> (len page-plans) 0)
(div (~tw :tokens "space-y-4 mt-4")
(map (fn (plan)
(div (~tw :tokens "rounded border border-stone-200 p-4")
(div (~tw :tokens "flex items-center justify-between mb-3")
(div
(span (~tw :tokens "font-mono font-medium text-stone-800") (get plan "name"))
(span (~tw :tokens "text-stone-400 ml-2 text-sm") (get plan "path")))
(div (~tw :tokens "flex gap-2")
(span (~tw :tokens "inline-block px-2 py-0.5 rounded text-xs font-bold bg-orange-100 text-orange-700")
(str (get plan "server-count") " server"))
(span (~tw :tokens "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-100 text-green-700")
(str (get plan "client-count") " client"))))
(when (> (get plan "server-count") 0)
(div (~tw :tokens "mb-2")
(span (~tw :tokens "text-xs font-medium text-stone-500 uppercase") "Server-expanded: ")
(span (~tw :tokens "text-sm font-mono text-orange-700")
(join " " (get plan "server")))))
(when (> (get plan "client-count") 0)
(div
(span (~tw :tokens "text-xs font-medium text-stone-500 uppercase") "Client-rendered: ")
(span (~tw :tokens "text-sm font-mono text-green-700")
(join " " (get plan "client")))))))
page-plans))))
;; How it integrates
(~docs/section :title "How It Works" :id "how"
(ol (~tw :tokens "list-decimal list-inside text-stone-700 space-y-2")
(li (code "defcomp") " parses " (code ":affinity") " annotation between params and body")
(li "Component object stores " (code "affinity") " field (\"auto\", \"client\", or \"server\")")
(li (code "compute-all-io-refs") " scans transitive IO deps at registration time")
(li (code "render-target") " in deps.sx combines affinity + IO analysis → \"server\" or \"client\"")
(li "Server partial evaluator (" (code "_aser") ") checks " (code "render_target") ":")
(ul (~tw :tokens "list-disc pl-8 text-stone-600")
(li "\"server\" → expand component, embed rendered HTML")
(li "\"client\" → serialize as SX, let client render"))
(li "Client routing uses same info: IO deps in page registry → proxy registration")))
;; Verification
(div (~tw :tokens "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2")
(p (~tw :tokens "font-semibold text-amber-800") "How to verify")
(ol (~tw :tokens "list-decimal list-inside text-amber-700 space-y-1")
(li "View page source — components with render-target \"server\" are expanded to HTML")
(li "Components with render-target \"client\" appear as " (code "(~plans/content-addressed-components/name ...)") " in the SX wire format")
(li "Navigate away and back — client-routable pure components render instantly")
(li "Check the analysis table above — it shows live data from the server's component registry")))))

View File

@@ -0,0 +1,67 @@
;; Async IO demo — Phase 5 client-side rendering with IO primitives.
;;
;; This component calls `highlight` inline — an IO primitive that runs
;; server-side Python. When rendered on the server, it executes
;; synchronously. When rendered client-side, the async renderer proxies
;; the call via /sx/io/highlight and awaits the result.
;;
;; `highlight` returns SxExpr — SX source with colored spans — which the
;; evaluator renders as DOM. The same SxExpr flows through the IO proxy:
;; server serializes → client parses → async renderer renders to DOM.
;;
;; Open browser console and look for:
;; "sx:route client+async" — async render with IO proxy
;; "sx:io registered N proxied primitives" — IO proxy initialization
(defcomp ()
(div (~tw :tokens "space-y-8")
(div (~tw :tokens "border-b border-stone-200 pb-6")
(h1 (~tw :tokens "text-2xl font-bold text-stone-900") "Async IO Demo")
(p (~tw :tokens "mt-2 text-stone-600")
"This page calls " (code (~tw :tokens "bg-stone-100 px-1 rounded text-violet-700") "highlight")
" inline — an IO primitive that returns SX source with colored spans. "
"On the server it runs Python directly. On the client it proxies via "
(code (~tw :tokens "bg-stone-100 px-1 rounded text-violet-700") "/sx/io/highlight")
" and the async renderer awaits the result."))
;; Live syntax-highlighted code blocks — each is an IO call
(div (~tw :tokens "space-y-6")
(h2 (~tw :tokens "text-lg font-semibold text-stone-800") "Live IO: syntax highlighting")
(div (~tw :tokens "rounded-lg border border-stone-200 bg-white p-5 space-y-3")
(h3 (~tw :tokens "text-sm font-medium text-stone-500 uppercase tracking-wide") "SX component definition")
(~docs/code :src
(highlight "(defcomp ~async-io-demo/card (&key title subtitle &rest children)\n (div :class \"border rounded-lg p-4 shadow-sm\"\n (h2 :class \"text-lg font-bold\" title)\n (when subtitle\n (p :class \"text-stone-500 text-sm\" subtitle))\n (div :class \"mt-3\" children)))" "lisp")))
(div (~tw :tokens "rounded-lg border border-stone-200 bg-white p-5 space-y-3")
(h3 (~tw :tokens "text-sm font-medium text-stone-500 uppercase tracking-wide") "Python server code")
(~docs/code :src
(highlight "from shared.sx.pages import mount_io_endpoint\n\n# The IO proxy serves any allowed primitive:\n# GET /sx/io/highlight?_arg0=code&_arg1=lisp\nasync def io_proxy(name):\n result = await execute_io(name, args, kwargs, ctx)\n return serialize(result)" "python")))
(div (~tw :tokens "rounded-lg border border-stone-200 bg-white p-5 space-y-3")
(h3 (~tw :tokens "text-sm font-medium text-stone-500 uppercase tracking-wide") "SX async rendering spec")
(~docs/code :src
(highlight ";; try-client-route reads io-deps from page registry\n(let ((io-deps (get match \"io-deps\"))\n (has-io (and io-deps (not (empty? io-deps)))))\n ;; Register IO deps as proxied primitives on demand\n (when has-io (register-io-deps io-deps))\n (if has-io\n ;; Async render: IO primitives proxied via /sx/io/<name>\n (do\n (try-async-eval-content content-src env\n (fn (rendered)\n (when rendered\n (swap-rendered-content target rendered pathname))))\n true)\n ;; Sync render: pure components, no IO\n (let ((rendered (try-eval-content content-src env)))\n (swap-rendered-content target rendered pathname))))" "lisp"))))
;; Architecture explanation
(div (~tw :tokens "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3")
(h2 (~tw :tokens "text-lg font-semibold text-blue-900") "How it works")
(ol (~tw :tokens "list-decimal list-inside text-blue-800 space-y-2 text-sm")
(li "Server renders the page — " (code "highlight") " runs Python directly")
(li "Client receives component definitions including " (code "~async-io-demo/content"))
(li "On client navigation, " (code "io-deps") " list routes to async renderer")
(li (code "register-io-deps") " ensures each IO name is proxied via " (code "registerProxiedIo"))
(li "Proxied call: " (code "fetch(\"/sx/io/highlight?_arg0=...&_arg1=lisp\")"))
(li "Server runs highlight, returns SX source (colored span elements)")
(li "Client parses SX → AST, async renderer recursively renders to DOM")))
;; Verification instructions
(div (~tw :tokens "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2")
(p (~tw :tokens "font-semibold text-amber-800") "How to verify async IO rendering")
(ol (~tw :tokens "list-decimal list-inside text-amber-700 space-y-1")
(li "Open the browser console (F12)")
(li "Navigate to another page (e.g. Data Test)")
(li "Click back to this page")
(li "Look for: " (code (~tw :tokens "bg-amber-100 px-1 rounded") "sx:route client+async /isomorphism/async-io"))
(li "The code blocks should render identically — same syntax highlighting")
(li "Check Network tab: you'll see 3 requests to " (code (~tw :tokens "bg-amber-100 px-1 rounded") "/sx/io/highlight"))))))

View File

@@ -0,0 +1,21 @@
(defcomp (&key (comp-name :as string) (is-pure :as boolean) (io-refs :as list) (deps :as list) (source :as string))
(details :class (str "rounded border "
(if is-pure "border-blue-200 bg-blue-50" "border-amber-200 bg-amber-50"))
(summary (~tw :tokens "px-3 py-2 cursor-pointer hover:opacity-80 transition-opacity")
(div (~tw :tokens "flex items-center justify-between")
(div (~tw :tokens "flex items-center gap-2")
(span :class (str "inline-block w-2 h-2 rounded-full "
(if is-pure "bg-blue-500" "bg-amber-500")))
(span (~tw :tokens "font-mono text-sm font-medium text-stone-800") comp-name))
(div (~tw :tokens "flex items-center gap-2")
(when (not (empty? io-refs))
(span (~tw :tokens "text-xs text-amber-700")
(str "IO: " (join ", " io-refs))))
(when (not (empty? deps))
(span (~tw :tokens "text-xs text-stone-500")
(str (len deps) " deps"))))))
;; SX source (shown when component expanded)
(div (~tw :tokens "not-prose border-t border-stone-200 p-3 bg-stone-100 rounded-b")
(pre (~tw :tokens "text-xs leading-relaxed whitespace-pre-wrap overflow-x-auto")
(code (highlight source "lisp"))))))

View File

@@ -0,0 +1,36 @@
(defcomp (&key (name :as string) (path :as string) (needed :as number) (direct :as number) (total :as number) (pct :as number) (savings :as number)
(io-refs :as list) (pure-in-page :as number) (io-in-page :as number) (components :as list))
(details (~tw :tokens "rounded border border-stone-200")
(summary (~tw :tokens "p-4 cursor-pointer hover:bg-stone-50 transition-colors")
(div (~tw :tokens "flex items-center justify-between mb-2")
(div
(span (~tw :tokens "font-mono font-semibold text-stone-800") name)
(span (~tw :tokens "text-stone-400 text-sm ml-2") path))
(div (~tw :tokens "flex items-center gap-2")
(span (~tw :tokens "inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800")
(str pure-in-page " pure"))
(span (~tw :tokens "inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800")
(str io-in-page " IO"))
(div (~tw :tokens "text-right")
(span (~tw :tokens "font-mono text-sm")
(span (~tw :tokens "text-violet-700 font-bold") (str needed))
(span (~tw :tokens "text-stone-400") (str " / " total)))
(span (~tw :tokens "ml-2 inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800")
(str savings "% saved")))))
(div (~tw :tokens "w-full bg-stone-200 rounded-full h-2.5")
(div (~tw :tokens "bg-violet-600 h-2.5 rounded-full transition-all")
:style (str "width: " pct "%"))))
;; Component tree (shown when expanded)
(div (~tw :tokens "border-t border-stone-200 p-4 bg-stone-50")
(div (~tw :tokens "text-xs font-medium text-stone-500 uppercase tracking-wide mb-3")
(str needed " components in bundle"))
(div (~tw :tokens "space-y-1")
(map (fn (comp)
(~analyzer/component
:comp-name (get comp "name")
:is-pure (get comp "is-pure")
:io-refs (get comp "io-refs")
:deps (get comp "deps")
:source (get comp "source")))
components)))))

View File

@@ -0,0 +1,4 @@
(defcomp (&key (label :as string) (value :as string) (cls :as string))
(div (~tw :tokens "rounded-lg border border-stone-200 p-4 text-center")
(div :class (str "text-3xl font-bold " cls) value)
(div (~tw :tokens "text-sm text-stone-500 mt-1") label)))

View File

@@ -0,0 +1,55 @@
;; Bundle analyzer — live demonstration of dependency analysis + IO detection.
;; Shows per-page component bundles vs total, visualizing payload savings.
;; Drill down into each bundle to see component tree; expand to see SX source.
;; @css bg-green-100 text-green-800 bg-violet-600 bg-stone-200 text-violet-600 text-stone-600 text-green-600 rounded-full h-2.5 grid-cols-3 bg-blue-100 text-blue-800 bg-amber-100 text-amber-800 grid-cols-4 marker:text-stone-400 bg-blue-50 bg-amber-50 text-blue-700 text-amber-700 border-blue-200 border-amber-200 bg-blue-500 bg-amber-500
(defcomp (&key (pages :as list) (total-components :as number) (total-macros :as number)
(pure-count :as number) (io-count :as number))
(~docs/page :title "Page Bundle Analyzer"
(p (~tw :tokens "text-stone-600 mb-6")
"Live analysis of component dependency graphs and IO classification across all pages. "
"Each bar shows how many of the "
(strong (str total-components))
" total components a page actually needs, computed by the "
(a :href "/sx/(language.(spec.deps))" (~tw :tokens "text-violet-700 underline") "deps.sx")
" transitive closure algorithm. "
"Click a page to see its component tree; expand a component to see its SX source.")
(div (~tw :tokens "mb-8 grid grid-cols-4 gap-4")
(~analyzer/stat :label "Total Components" :value (str total-components)
:cls "text-violet-600")
(~analyzer/stat :label "Total Macros" :value (str total-macros)
:cls "text-stone-600")
(~analyzer/stat :label "Pure Components" :value (str pure-count)
:cls "text-blue-600")
(~analyzer/stat :label "IO-Dependent" :value (str io-count)
:cls "text-amber-600"))
(~docs/section :title "Per-Page Bundles" :id "bundles"
(div (~tw :tokens "space-y-3")
(map (fn (page)
(~analyzer/row
:name (get page "name")
:path (get page "path")
:needed (get page "needed")
:direct (get page "direct")
:total total-components
:pct (get page "pct")
:savings (get page "savings")
:io-refs (get page "io-refs")
:pure-in-page (get page "pure-in-page")
:io-in-page (get page "io-in-page")
:components (get page "components")))
pages)))
(~docs/section :title "How It Works" :id "how"
(ol (~tw :tokens "list-decimal pl-5 space-y-2 text-stone-700")
(li (strong "Scan: ") "Regex finds all " (code "(~plans/content-addressed-components/name") " patterns in the page's content expression.")
(li (strong "Resolve: ") "Each referenced component's body AST is walked to find transitive " (code "~") " references.")
(li (strong "Closure: ") "The full set is the union of direct + transitive deps, following chains through the component graph.")
(li (strong "Bundle: ") "Only these component definitions are serialized into the page payload. Everything else is omitted.")
(li (strong "IO detect: ") "Each component body is scanned for references to IO primitives (frag, query, service, etc.). Components with zero transitive IO refs are pure — safe for client rendering."))
(p (~tw :tokens "mt-4 text-stone-600")
"The analysis handles circular references (via seen-set), "
"walks all branches of control flow (if/when/cond/case), "
"and includes macro definitions shared across components."))))

View File

@@ -0,0 +1,76 @@
;; Offline data layer demo — exercises Phase 7d offline mutation queue.
;;
;; Shows connectivity status, note list, and offline mutation queue.
;; When offline, mutations are queued locally. On reconnect, they sync.
;;
;; Open browser console and look for:
;; "sx:offline queued" — mutation added to queue while offline
;; "sx:offline syncing" — reconnected, replaying queued mutations
;; "sx:offline synced" — individual mutation confirmed by server
(defcomp (&key notes server-time)
(div (~tw :tokens "space-y-8")
(div (~tw :tokens "border-b border-stone-200 pb-6")
(h1 (~tw :tokens "text-2xl font-bold text-stone-900") "Offline Data Layer")
(p (~tw :tokens "mt-2 text-stone-600")
"This page tests Phase 7d offline capabilities. Mutations made while "
"offline are queued locally and replayed when connectivity returns."))
;; Connectivity indicator
(div (~tw :tokens "rounded-lg border border-stone-200 bg-white p-6 space-y-3")
(h2 (~tw :tokens "text-lg font-semibold text-stone-800") "Status")
(dl (~tw :tokens "grid grid-cols-2 gap-2 text-sm")
(dt (~tw :tokens "font-medium text-stone-600") "Server time")
(dd (~tw :tokens "font-mono text-stone-900") server-time)
(dt (~tw :tokens "font-medium text-stone-600") "Notes count")
(dd (~tw :tokens "text-stone-900") (str (len notes)))
(dt (~tw :tokens "font-medium text-stone-600") "Connectivity")
(dd (~tw :tokens "text-stone-900")
(span :id "offline-status" (~tw :tokens "inline-flex items-center gap-1.5")
(span (~tw :tokens "w-2 h-2 rounded-full bg-green-500"))
"Online"))))
;; Note list
(div (~tw :tokens "space-y-3")
(h2 (~tw :tokens "text-lg font-semibold text-stone-800") "Notes")
(div :id "offline-notes" (~tw :tokens "space-y-2")
(map (fn (note)
(div (~tw :tokens "flex items-center justify-between rounded border border-stone-100 bg-white p-3")
(div (~tw :tokens "flex items-center gap-3")
(span (~tw :tokens "flex-none rounded-full bg-blue-100 text-blue-700 w-6 h-6 flex items-center justify-center text-xs font-bold")
(str (get note "id")))
(span (~tw :tokens "text-stone-900") (get note "text")))
(span (~tw :tokens "text-xs text-stone-400 font-mono")
(get note "created"))))
notes)))
;; Architecture
(div (~tw :tokens "space-y-4")
(h2 (~tw :tokens "text-lg font-semibold text-stone-800") "How it works")
(div (~tw :tokens "space-y-2")
(map-indexed
(fn (i step)
(div (~tw :tokens "flex items-start gap-3 rounded border border-stone-100 bg-white p-3")
(span (~tw :tokens "flex-none rounded-full bg-stone-100 text-stone-700 w-6 h-6 flex items-center justify-center text-xs font-bold")
(str (+ i 1)))
(div
(div (~tw :tokens "font-medium text-stone-900") (get step "label"))
(div (~tw :tokens "text-sm text-stone-500") (get step "detail")))))
(list
(dict :label "Online mutation" :detail "Routes through submit-mutation (Phase 7c) — optimistic predict, server confirm/revert")
(dict :label "Offline detection" :detail "Browser 'offline' event sets _is-online to false. offline-aware-mutation routes to queue")
(dict :label "Queue mutation" :detail "Mutation stored in _offline-queue with 'pending' status. Optimistic update applied locally")
(dict :label "Reconnect" :detail "Browser 'online' event triggers offline-sync — replays queue in order via execute-action")
(dict :label "Sync result" :detail "Each mutation marked 'synced' or 'failed'. Failed mutations stay for manual retry")))))
;; How to test
(div (~tw :tokens "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2")
(p (~tw :tokens "font-semibold text-amber-800") "How to test offline behavior")
(ol (~tw :tokens "list-decimal list-inside text-amber-700 space-y-1")
(li "Open the browser console and Network tab")
(li "Navigate to this page via client-side routing")
(li "In DevTools, set Network to \"Offline\" mode")
(li "The connectivity indicator should change to red/Offline")
(li "Watch console for " (code (~tw :tokens "bg-amber-100 px-1 rounded") "sx:offline queued"))
(li "Re-enable network — watch for " (code (~tw :tokens "bg-amber-100 px-1 rounded") "sx:offline syncing"))
(li "Queued mutations replay and confirm or fail")))))

View File

@@ -0,0 +1,103 @@
(defcomp
(&key items server-time)
(div
(~tw :tokens "space-y-8")
(div
(~tw :tokens "border-b border-stone-200 pb-6")
(h1 (~tw :tokens "text-2xl font-bold text-stone-900") "Optimistic Updates")
(p
(~tw :tokens "mt-2 text-stone-600")
"This page tests Phase 7c optimistic data mutations. Items are updated "
"instantly on the client, then confirmed or reverted when the server responds."))
(div
(~tw :tokens "rounded-lg border border-stone-200 bg-white p-6 space-y-3")
(h2 (~tw :tokens "text-lg font-semibold text-stone-800") "Current state")
(dl
(~tw :tokens "grid grid-cols-2 gap-2 text-sm")
(dt (~tw :tokens "font-medium text-stone-600") "Server time")
(dd (~tw :tokens "font-mono text-stone-900") server-time)
(dt (~tw :tokens "font-medium text-stone-600") "Item count")
(dd (~tw :tokens "text-stone-900") (str (len items)))))
(div
(~tw :tokens "space-y-3")
(h2 (~tw :tokens "text-lg font-semibold text-stone-800") "Items")
(div
:id "optimistic-items"
(~tw :tokens "space-y-2")
(map
(fn
(item)
(div
(~tw :tokens "flex items-center justify-between rounded border border-stone-100 bg-white p-3")
(div
(~tw :tokens "flex items-center gap-3")
(span
(~tw :tokens "flex-none rounded-full bg-violet-100 text-violet-700 w-6 h-6 flex items-center justify-center text-xs font-bold")
(str (get item "id")))
(span (~tw :tokens "text-stone-900") (get item "label")))
(span
(~tw :tokens "text-xs px-2 py-0.5 rounded-full")
:class (case
(get item "status")
"confirmed"
"bg-green-100 text-green-700"
"pending"
"bg-amber-100 text-amber-700"
"reverted"
"bg-red-100 text-red-700"
:else "bg-stone-100 text-stone-500")
(get item "status"))))
items))
(div
(~tw :tokens "pt-2")
(button
(~tw :tokens "px-4 py-2 bg-violet-500 text-white rounded hover:bg-violet-600 text-sm")
:sx-post "/sx/action/add-demo-item"
:sx-target "#sx-content"
:sx-vals "{\"label\": \"New item\"}"
"Add item (optimistic)")))
(div
(~tw :tokens "space-y-4")
(h2 (~tw :tokens "text-lg font-semibold text-stone-800") "How it works")
(div
(~tw :tokens "space-y-2")
(map-indexed
(fn
(i step)
(div
(~tw :tokens "flex items-start gap-3 rounded border border-stone-100 bg-white p-3")
(span
(~tw :tokens "flex-none rounded-full bg-stone-100 text-stone-700 w-6 h-6 flex items-center justify-center text-xs font-bold")
(str (+ i 1)))
(div
(div (~tw :tokens "font-medium text-stone-900") (get step "label"))
(div (~tw :tokens "text-sm text-stone-500") (get step "detail")))))
(list
(dict
:label "Predict"
:detail "Client applies mutator function to cached data immediately")
(dict
:label "Snapshot"
:detail "Pre-mutation data saved in _optimistic-snapshots for rollback")
(dict
:label "Re-render"
:detail "Page content re-evaluated and swapped with predicted data")
(dict
:label "Submit"
:detail "Mutation sent to server via POST /sx/action/<name>")
(dict
:label "Confirm or revert"
:detail "Server responds — cache updated with truth, or reverted to snapshot")))))
(div
(~tw :tokens "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2")
(p (~tw :tokens "font-semibold text-amber-800") "How to verify")
(ol
(~tw :tokens "list-decimal list-inside text-amber-700 space-y-1")
(li "Open the browser console (F12)")
(li "Navigate to this page from another isomorphism page")
(li
"Click \"Add item\" — item appears instantly with \"pending\" status")
(li
"Watch console for "
(code (~tw :tokens "bg-amber-100 px-1 rounded") "sx:optimistic confirmed"))
(li "Item status changes to \"confirmed\" when server responds")))))

View File

@@ -0,0 +1,35 @@
(defcomp
(&key
(name :as string)
(path :as string)
(mode :as string)
(has-data :as boolean)
(content-expr :as string?)
(reason :as string?))
(div
:class (str
"rounded border p-3 flex items-center gap-3 "
(if
(= mode "client")
"border-green-200 bg-green-50"
"border-amber-200 bg-amber-50"))
(span
:class (str
"inline-block px-2 py-0.5 rounded text-xs font-bold uppercase "
(if
(= mode "client")
"bg-green-600 text-white"
"bg-amber-500 text-white"))
mode)
(div
(~tw :tokens "flex-1 min-w-0")
(div
(~tw :tokens "flex items-center gap-2")
(span (~tw :tokens "font-mono font-semibold text-stone-800 text-sm") name)
(span (~tw :tokens "text-stone-400 text-xs font-mono") path))
(when reason (div (~tw :tokens "text-xs text-stone-500 mt-0.5") reason)))
(when
content-expr
(div
(~tw :tokens "hidden md:block max-w-xs truncate")
(code (~tw :tokens "text-xs text-stone-500") content-expr)))))

View File

@@ -0,0 +1,137 @@
(defcomp
(&key pages total-pages client-count server-count registry-sample)
(~docs/page
:title "Routing Analyzer"
(p
(~tw :tokens "text-stone-600 mb-6")
"Live classification of all "
(strong (str total-pages))
" pages by routing mode. "
"Pages without "
(code ":data")
" dependencies are "
(span (~tw :tokens "text-green-700 font-medium") "client-routable")
" — after initial load they render instantly from the page registry without a server roundtrip. "
"Pages with data dependencies fall back to "
(span (~tw :tokens "text-amber-700 font-medium") "server fetch")
" transparently. Powered by "
(a
:href "/sx/(language.(spec.router))"
(~tw :tokens "text-violet-700 underline")
"router.sx")
" route matching and "
(a
:href "/sx/(language.(spec.deps))"
(~tw :tokens "text-violet-700 underline")
"deps.sx")
" IO detection.")
(div
(~tw :tokens "mb-8 grid grid-cols-4 gap-4")
(~analyzer/stat
:label "Total Pages"
:value (str total-pages)
:cls "text-violet-600")
(~analyzer/stat
:label "Client-Routable"
:value (str client-count)
:cls "text-green-600")
(~analyzer/stat
:label "Server-Only"
:value (str server-count)
:cls "text-amber-600")
(~analyzer/stat
:label "Client Ratio"
:value (str (round (* (/ client-count total-pages) 100)) "%")
:cls "text-blue-600"))
(div
(~tw :tokens "mb-8")
(div
(~tw :tokens "flex items-center gap-2 mb-2")
(span (~tw :tokens "text-sm font-medium text-stone-600") "Client")
(div (~tw :tokens "flex-1"))
(span (~tw :tokens "text-sm font-medium text-stone-600") "Server"))
(div
(~tw :tokens "w-full bg-amber-200 rounded-full h-4 overflow-hidden")
(div
(~tw :tokens "bg-green-500 h-4 rounded-l-full transition-all")
:style (str "width: " (round (* (/ client-count total-pages) 100)) "%"))))
(~docs/section
:title "Route Table"
:id "routes"
(div
(~tw :tokens "space-y-2")
(map
(fn
(page)
(~routing-analyzer/routing-row
:name (get page "name")
:path (get page "path")
:mode (get page "mode")
:has-data (get page "has-data")
:content-expr (get page "content-expr")
:reason (get page "reason")))
pages)))
(~docs/section
:title "Page Registry Format"
:id "registry"
(p
(~tw :tokens "text-stone-600 mb-4")
"The server serializes page metadata as SX dict literals inside "
(code "<script type=\"text/sx-pages\">")
". The client's parser reads these at boot, building a route table with parsed URL patterns. "
"No JSON involved — the same SX parser handles everything.")
(when
(not (empty? registry-sample))
(div
(~tw :tokens "not-prose")
(pre
(~tw :tokens "text-xs leading-relaxed whitespace-pre-wrap overflow-x-auto bg-stone-100 rounded border border-stone-200 p-4")
(code (highlight registry-sample "lisp"))))))
(~docs/section
:title "How Client Routing Works"
:id "how"
(ol
(~tw :tokens "list-decimal pl-5 space-y-2 text-stone-700")
(li
(strong "Boot: ")
"boot.sx finds "
(code "<script type=\"text/sx-pages\">")
", calls "
(code "parse")
" on the SX content, then "
(code "parse-route-pattern")
" on each page's path to build "
(code "_page-routes")
".")
(li
(strong "Click: ")
"orchestration.sx intercepts boost link clicks via "
(code "bind-client-route-link")
". Extracts the pathname from the href.")
(li
(strong "Match: ")
(code "find-matching-route")
" from router.sx tests the pathname against all parsed patterns. Returns the first match with extracted URL params.")
(li
(strong "Check: ")
"If the matched page has "
(code ":has-data true")
", skip to server fetch. Otherwise proceed to client eval.")
(li
(strong "Eval: ")
(code "try-eval-content")
" merges the component env + URL params + closure, then parses and renders the content expression to DOM.")
(li
(strong "Swap: ")
"On success, the rendered DOM replaces "
(code "#sx-content")
" contents, "
(code "pushState")
" updates the URL, and the console logs "
(code "sx:route client /path")
".")
(li
(strong "Fallback: ")
"If anything fails (no match, eval error, missing component), the click falls through to a standard server fetch. Console logs "
(code "sx:route server /path")
". The user sees no difference.")))))

View File

@@ -0,0 +1,65 @@
(defisland
()
(let
((price (def-store "demo-price" (fn () (signal 19.99))))
(qty (signal 1))
(total (computed (fn () (* (deref price) (deref qty)))))
(savings
(computed (fn () (- (* 19.99 (deref qty)) (deref total)))))
(on-sale? (computed (fn () (< (deref price) 19.99)))))
(div
(~tw :tokens "rounded-lg border border-stone-200 bg-white p-4 my-4 space-y-4")
(div
(~tw :tokens "flex items-center justify-between")
(div
(h4 (~tw :tokens "font-semibold text-stone-800") "Artisan Widget")
(p (~tw :tokens "text-sm text-stone-500") "Hand-crafted by algorithms"))
(div
(~tw :tokens "text-right")
(div
(~tw :tokens "flex items-center gap-2")
(when
(deref on-sale?)
(span
(~tw :tokens "px-2 py-0.5 rounded-full bg-rose-100 text-rose-700 text-xs font-bold uppercase")
"Sale"))
(span
(~tw :tokens "text-2xl font-bold text-stone-800")
"$"
(deref price)))))
(div
(~tw :tokens "flex items-center gap-3")
(span (~tw :tokens "text-sm text-stone-600") "Qty:")
(button
(~tw :tokens "w-7 h-7 rounded bg-stone-200 text-stone-700 text-sm font-medium hover:bg-stone-300")
:on-click (fn (e) (swap! qty (fn (q) (max 1 (- q 1)))))
"")
(span
(~tw :tokens "font-mono text-lg font-bold text-violet-900 w-8 text-center")
(deref qty))
(button
(~tw :tokens "w-7 h-7 rounded bg-stone-200 text-stone-700 text-sm font-medium hover:bg-stone-300")
:on-click (fn (e) (swap! qty inc))
"+")
(span (~tw :tokens "ml-4 text-sm text-stone-600") "Total:")
(span (~tw :tokens "text-lg font-bold text-emerald-700") "$" (deref total))
(when
(> (deref savings) 0)
(span
(~tw :tokens "text-sm text-rose-600 font-medium ml-2")
"Save $"
(deref savings))))
(div
(~tw :tokens "border-t border-stone-200 pt-3")
(div
(~tw :tokens "flex items-center gap-3")
(button
(~tw :tokens "px-4 py-2 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:sx-get "/sx/(geography.(reactive.(api.flash-sale)))"
:sx-target "#marsh-server-msg"
:sx-swap "innerHTML"
"Fetch Price from Server")
(span
(~tw :tokens "text-xs text-stone-400")
"Hits a real endpoint. Response updates the signal."))
(div :id "marsh-server-msg" (~tw :tokens "mt-2 min-h-[2rem]"))))))

View File

@@ -0,0 +1,35 @@
(defisland
()
(let
((count (def-store "settle-count" (fn () (signal 0)))))
(div
(~tw :tokens "rounded-lg border border-stone-200 bg-white p-4 my-4 space-y-3")
(div
(~tw :tokens "flex items-center justify-between")
(div
(h4 (~tw :tokens "font-semibold text-stone-800") "Settle Hook Demo")
(p
(~tw :tokens "text-sm text-stone-500")
"Server content + client-side counter"))
(div
(~tw :tokens "text-right")
(span (~tw :tokens "text-sm text-stone-600") "Fetched: ")
(span (~tw :tokens "text-2xl font-bold text-violet-700") (deref count))
(span (~tw :tokens "text-sm text-stone-600") " times")))
(div
(~tw :tokens "flex items-center gap-3")
(button
(~tw :tokens "px-4 py-2 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:sx-get "/sx/(geography.(reactive.(api.settle-data)))"
:sx-target "#settle-result"
:sx-swap "innerHTML"
:sx-on-settle "(swap! (use-store \"settle-count\") inc)"
"Fetch Item")
(button
(~tw :tokens "px-3 py-1.5 rounded bg-stone-200 text-stone-700 text-sm font-medium hover:bg-stone-300")
:on-click (fn (e) (reset! count 0))
"Reset Counter"))
(div
:id "settle-result"
(~tw :tokens "min-h-[2rem] rounded bg-stone-50 p-2")
(p (~tw :tokens "text-sm text-stone-400 italic") "Nothing fetched yet.")))))

View File

@@ -0,0 +1,84 @@
(defisland
()
(let
((mode (signal "products")) (query (signal "")))
(div
(~tw :tokens "rounded-lg border border-stone-200 bg-white p-4 my-4 space-y-3")
(h4 (~tw :tokens "font-semibold text-stone-800") "Signal-Bound URL Demo")
(div
(~tw :tokens "flex gap-2")
(button
:class (computed
(fn
()
(str
"px-3 py-1.5 rounded text-sm font-medium "
(if
(= (deref mode) "products")
"bg-violet-600 text-white"
"bg-stone-200 text-stone-700 hover:bg-stone-300"))))
:on-click (fn (e) (reset! mode "products"))
"Products")
(button
:class (computed
(fn
()
(str
"px-3 py-1.5 rounded text-sm font-medium "
(if
(= (deref mode) "events")
"bg-emerald-600 text-white"
"bg-stone-200 text-stone-700 hover:bg-stone-300"))))
:on-click (fn (e) (reset! mode "events"))
"Events")
(button
:class (computed
(fn
()
(str
"px-3 py-1.5 rounded text-sm font-medium "
(if
(= (deref mode) "posts")
"bg-amber-600 text-white"
"bg-stone-200 text-stone-700 hover:bg-stone-300"))))
:on-click (fn (e) (reset! mode "posts"))
"Posts"))
(div
(~tw :tokens "flex gap-2")
(input
:type "text"
:bind query
:placeholder "Search..."
(~tw :tokens "flex-1 px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400"))
(button
(~tw :tokens "px-4 py-1.5 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:sx-get (computed
(fn
()
(str
"/sx/(geography.(reactive.(api.search-"
(deref mode)
")))"
"?q="
(deref query))))
:sx-target "#signal-results"
:sx-swap "innerHTML"
"Search"))
(p
(~tw :tokens "text-xs text-stone-400 font-mono")
"URL: "
(computed
(fn
()
(str
"/sx/(geography.(reactive.(api.search-"
(deref mode)
")))"
"?q="
(deref query)))))
(div
:id "signal-results"
(~tw :tokens "min-h-[3rem] rounded bg-stone-50 p-2")
(p
(~tw :tokens "text-sm text-stone-400 italic")
"Select a category and search.")))))

View File

@@ -0,0 +1,23 @@
(defisland
()
(let
((price (def-store "demo-price" (fn () (signal 19.99)))))
(div
(~tw :tokens "rounded-lg border border-emerald-200 bg-emerald-50 p-4")
(div
(~tw :tokens "flex items-center justify-between")
(div
(~tw :tokens "flex items-center gap-2")
(span
(~tw :tokens "text-sm font-semibold text-emerald-800")
"Island B: Product Display")
(span
(~tw :tokens "text-xs bg-emerald-200 text-emerald-700 px-1.5 py-0.5 rounded font-mono")
"use-store"))
(div
(~tw :tokens "flex items-center gap-2")
(span (~tw :tokens "text-stone-600") "Current price:")
(span (~tw :tokens "text-2xl font-bold text-stone-800") "$" (deref price))))
(p
(~tw :tokens "text-xs text-emerald-600 mt-2")
"Separate island, reads the same store. Signal changes propagate instantly across island boundaries."))))

View File

@@ -0,0 +1,42 @@
(defisland
()
(let
((price (def-store "demo-price" (fn () (signal 19.99)))))
(div
(~tw :tokens "rounded-lg border border-amber-200 bg-amber-50 p-4")
(div
(~tw :tokens "flex items-center justify-between mb-3")
(div
(~tw :tokens "flex items-center gap-2")
(span
(~tw :tokens "text-sm font-semibold text-amber-800")
"Island A: Price Control")
(span
(~tw :tokens "text-xs bg-amber-200 text-amber-700 px-1.5 py-0.5 rounded font-mono")
"def-store"))
(span (~tw :tokens "text-2xl font-bold text-stone-800") "$" (deref price)))
(div
(~tw :tokens "flex flex-wrap gap-2")
(button
(~tw :tokens "px-3 py-1.5 rounded bg-rose-500 text-white text-sm font-medium hover:bg-rose-600")
:on-click (fn (e) (reset! price 14.99))
"⚡$14.99")
(button
(~tw :tokens "px-3 py-1.5 rounded bg-rose-500 text-white text-sm font-medium hover:bg-rose-600")
:on-click (fn (e) (reset! price 9.99))
"⚡$9.99")
(button
(~tw :tokens "px-3 py-1.5 rounded bg-rose-500 text-white text-sm font-medium hover:bg-rose-600")
:on-click (fn (e) (reset! price 29.99))
"⚡$29.99")
(button
(~tw :tokens "px-3 py-1.5 rounded bg-stone-300 text-stone-700 text-sm font-medium hover:bg-stone-400")
:on-click (fn (e) (reset! price 19.99))
"Reset $19.99"))
(p
(~tw :tokens "text-xs text-amber-600 mt-2")
"Each button calls "
(code "(reset! price ...)")
" — simulating "
(code "data-sx-signal")
" during morph."))))

View File

@@ -0,0 +1,108 @@
(defisland
()
(let
((view (signal "list"))
(items (def-store "catalog-items" (fn () (signal (list))))))
(div
(~tw :tokens "rounded-lg border border-stone-200 bg-white p-4 my-4 space-y-3")
(h4 (~tw :tokens "font-semibold text-stone-800") "Reactive View Transform")
(div
(~tw :tokens "flex gap-2")
(button
:class (computed
(fn
()
(str
"px-3 py-1.5 rounded text-sm font-medium "
(if
(= (deref view) "list")
"bg-violet-600 text-white"
"bg-stone-200 text-stone-700 hover:bg-stone-300"))))
:on-click (fn (e) (reset! view "list"))
"List")
(button
:class (computed
(fn
()
(str
"px-3 py-1.5 rounded text-sm font-medium "
(if
(= (deref view) "grid")
"bg-violet-600 text-white"
"bg-stone-200 text-stone-700 hover:bg-stone-300"))))
:on-click (fn (e) (reset! view "grid"))
"Grid")
(button
:class (computed
(fn
()
(str
"px-3 py-1.5 rounded text-sm font-medium "
(if
(= (deref view) "compact")
"bg-violet-600 text-white"
"bg-stone-200 text-stone-700 hover:bg-stone-300"))))
:on-click (fn (e) (reset! view "compact"))
"Compact"))
(div
(~tw :tokens "flex items-center gap-3")
(button
(~tw :tokens "px-4 py-2 rounded bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700")
:sx-get "/sx/(geography.(reactive.(api.catalog)))"
:sx-target "#catalog-msg"
:sx-swap "innerHTML"
"Fetch Catalog")
(span
(~tw :tokens "text-xs text-stone-400")
"Server sends data + writes to signal"))
(div :id "catalog-msg" (~tw :tokens "min-h-[1.5rem]"))
(div
(~tw :tokens "min-h-[4rem]")
(when
(> (len (deref items)) 0)
(cond
(= (deref view) "list")
(div
(~tw :tokens "space-y-2")
(map
(fn
(item)
(div
(~tw :tokens "flex items-center justify-between p-2 rounded bg-stone-50 border border-stone-100")
(div
(span
(~tw :tokens "text-sm font-medium text-stone-800")
(get item "name"))
(span
(~tw :tokens "text-xs text-stone-500 block")
(get item "desc")))
(span
(~tw :tokens "text-sm font-bold text-emerald-700")
"$"
(get item "price"))))
(deref items)))
(= (deref view) "grid")
(div
(~tw :tokens "grid grid-cols-2 gap-2")
(map
(fn
(item)
(div
(~tw :tokens "p-3 rounded bg-violet-50 border border-violet-200 text-center")
(p
(~tw :tokens "text-sm font-semibold text-stone-800")
(get item "name"))
(p
(~tw :tokens "text-lg font-bold text-emerald-700")
"$"
(get item "price"))))
(deref items)))
:else (div
(~tw :tokens "flex flex-wrap gap-1")
(map
(fn
(item)
(span
(~tw :tokens "px-2 py-0.5 rounded bg-stone-100 text-xs text-stone-700")
(get item "name")))
(deref items)))))))))

View File

@@ -0,0 +1,29 @@
(defcomp
()
(~docs/page
:title "Hypermedia Feeds Reactive State"
(p
"Click \"Fetch Price\" to hit a real server endpoint. The response is "
(em "hypermedia")
" — SX content swapped into the page. But a "
(code "data-init")
" script in the response also writes to the "
(code "\"demo-price\"")
" store signal. The island's reactive UI — total, savings, price display — updates instantly from the signal change.")
(p
"This is the marsh pattern: "
(strong "the server response is both content and a signal write")
". Hypermedia and reactivity aren't separate — the same response does both.")
(~reactive-islands/marshes/demo-marsh-product)
(~docs/code
:src (highlight
";; Island with a store-backed price signal\n(defisland ~reactive-islands/marshes/demo-marsh-product ()\n (let ((price (def-store \"demo-price\" (fn () (signal 19.99))))\n (qty (signal 1))\n (total (computed (fn () (* (deref price) (deref qty))))))\n (div\n ;; Reactive price display — updates when store changes\n (span \"$\" (deref price))\n (span \"Qty:\") (button \"-\") (span (deref qty)) (button \"+\")\n (span \"Total: $\" (deref total))\n\n ;; Fetch from server — response arrives as hypermedia\n (button :sx-get \"/sx/(geography.(reactive.(api.flash-sale)))\"\n :sx-target \"#marsh-server-msg\"\n :sx-swap \"innerHTML\"\n \"Fetch Price\")\n ;; Server response lands here:\n (div :id \"marsh-server-msg\"))))"
"lisp"))
(~docs/code
:src (highlight
";; Server returns SX content + a data-init script:\n;;\n;; (<>\n;; (p \"Flash sale! Price: $14.99\")\n;; (script :type \"text/sx\" :data-init\n;; \"(reset! (use-store \\\"demo-price\\\") 14.99)\"))\n;;\n;; The <p> is swapped in as normal hypermedia content.\n;; The script writes to the store signal.\n;; The island's (deref price), total, and savings\n;; all update reactively — no re-render, no diffing."
"lisp"))
(p
"Two things happen from one server response: content appears in the swap target (hypermedia) and the price signal updates (reactivity). The island didn't fetch the price. The server didn't call a signal API. The response "
(em "is")
" both.")))

View File

@@ -0,0 +1,571 @@
(defcomp
()
(~docs/page
:title "Marshes"
(p
(~tw :tokens "text-stone-500 text-sm italic mb-8")
"Islands are dry land. Lakes are open water. Marshes are the saturated ground between — where you can't tell whether you're standing on reactivity or wading through hypermedia.")
(~docs/section
:title "The boundary dissolves"
:id "problem"
(p
"Islands and lakes establish a clear territorial agreement. Islands own reactive state — signals, computed, effects. Lakes own server content — morphed during navigation, updated by "
(code "sx-get")
"/"
(code "sx-post")
". The morph algorithm enforces the border: it enters islands, finds lakes, updates them, and leaves everything else untouched.")
(p
"But real applications need more than peaceful coexistence. They need "
(em "interpenetration")
":")
(ul
(~tw :tokens "list-disc pl-5 space-y-2 text-stone-600")
(li
"A server response that writes to a signal — the lake feeds the island.")
(li
"A reactive transform that reshapes server content before it enters the DOM — the island filters the water.")
(li
"A signal that controls which server endpoint to call, what swap target to use, which OOB slots to request — the island directs the current.")
(li
"Client state that changes "
(em "how")
" server content is rendered — not just what to fetch, but how to interpret what arrives."))
(p
"These aren't edge cases. They're what every non-trivial interactive application does. The island/lake model handles the 90% case; marshes handle the other 10% where the boundary needs to be soft."))
(~docs/section
:title "Three marsh patterns"
:id "patterns"
(p
"Marshes manifest in three directions, each reversing the flow between the reactive and hypermedia worlds.")
(~docs/subsection
:title "Pattern 1: Hypermedia writes to reactive state"
(p
"The server response carries data that should update a signal. The lake doesn't just display content — it "
(em "feeds")
" the island's reactive graph.")
(h4 (~tw :tokens "font-semibold mt-4 mb-2") "Mechanism: data-sx-signal")
(p
"A server-rendered element carries a "
(code "data-sx-signal")
" attribute naming a store signal and its new value. When the morph processes this element, it writes to the signal instead of (or in addition to) updating the DOM.")
(~docs/code
:src (highlight
";; Server response includes:\n(div :data-sx-signal \"cart-count:7\"\n (span \"7 items\"))\n\n;; The morph sees data-sx-signal, parses it:\n;; store name = \"cart-count\"\n;; value = 7\n;; Then: (reset! (use-store \"cart-count\") 7)\n;;\n;; Any island anywhere on the page that reads cart-count\n;; updates immediately — fine-grained, no re-render."
"lisp"))
(h4 (~tw :tokens "font-semibold mt-4 mb-2") "Mechanism: sx-on-settle")
(p
"An "
(code "sx-on-settle")
" attribute on a hypermedia trigger element. After the swap completes and the DOM settles, the SX expression is evaluated. This gives the response a chance to run arbitrary reactive logic.")
(~docs/code
:src (highlight
";; A search form that updates a signal after results arrive:\n(form :sx-post \"/search\" :sx-target \"#results\"\n :sx-on-settle (reset! (use-store \"result-count\") result-count)\n (input :name \"q\" :placeholder \"Search...\"))"
"lisp"))
(h4
(~tw :tokens "font-semibold mt-4 mb-2")
"Mechanism: event bridge (already exists)")
(p
"The event bridge ("
(code "data-sx-emit")
") already provides server → island communication via custom DOM events. Marshes generalise this: "
(code "data-sx-signal")
" is a declarative shorthand for the common case of \"server says update this value.\"")
(div
(~tw :tokens "rounded border border-violet-200 bg-violet-50 p-4 mt-4")
(p
(~tw :tokens "text-violet-900 font-medium")
"Why not just use the event bridge for everything?")
(p
(~tw :tokens "text-violet-800 text-sm")
"You can. "
(code "data-sx-emit")
" dispatches a custom event; an effect in an island listens and calls "
(code "reset!")
". "
(code "data-sx-signal")
" cuts out the boilerplate for the most common case. The event bridge remains the right tool for complex multi-step reactions.")))
(~docs/subsection
:title "Pattern 2: Hypermedia modifies reactive components"
(p
"Lake morphing lets the server update "
(em "content")
" inside an island. Marsh morphing goes further: the server can send new SX that the island evaluates reactively.")
(h4 (~tw :tokens "font-semibold mt-4 mb-2") "Mechanism: marsh tag")
(p
"A "
(code "marsh")
" is a zone inside an island where server content is "
(em "re-evaluated")
" by the island's reactive evaluator, not just inserted as static DOM. When the morph updates a marsh, the new content is parsed as SX and rendered in the island's signal context.")
(~docs/code
:src (highlight
";; Inside an island — a marsh re-evaluates on morph:\n(defisland ~reactive-islands/marshes/product-card (&key product-id)\n (let ((quantity (signal 1))\n (variant (signal nil)))\n (div :class \"card\"\n ;; Lake: server content, inserted as static HTML\n (lake :id \"description\"\n (p \"Loading...\"))\n ;; Marsh: server content, evaluated with access to island signals\n (marsh :id \"controls\"\n ;; Initial content from server — has signal references:\n (div\n (select :bind variant\n (option :value \"red\" \"Red\")\n (option :value \"blue\" \"Blue\"))\n (input :type \"number\" :bind quantity))))))"
"lisp"))
(p
"When the server sends updated marsh content (e.g., new variant options fetched from a database), the island re-evaluates it in its signal scope. The new "
(code "select")
" options bind to the existing "
(code "variant")
" signal. The reactive graph reconnects seamlessly.")
(h4 (~tw :tokens "font-semibold mt-4 mb-2") "Lake vs. Marsh")
(div
(~tw :tokens "overflow-x-auto rounded border border-stone-200 mb-4")
(table
(~tw :tokens "w-full text-left text-sm")
(thead
(tr
(~tw :tokens "border-b border-stone-200 bg-stone-100")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Lake")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Marsh")))
(tbody
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 font-medium text-stone-700") "Content")
(td (~tw :tokens "px-3 py-2 text-stone-600") "Static HTML")
(td (~tw :tokens "px-3 py-2 text-stone-600") "SX expressions"))
(tr
(~tw :tokens "border-b border-stone-100")
(td
(~tw :tokens "px-3 py-2 font-medium text-stone-700")
"Morph action")
(td (~tw :tokens "px-3 py-2 text-stone-600") "Replace DOM children")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Parse SX, evaluate in island scope, replace DOM"))
(tr
(~tw :tokens "border-b border-stone-100")
(td
(~tw :tokens "px-3 py-2 font-medium text-stone-700")
"Signal access")
(td (~tw :tokens "px-3 py-2 text-stone-600") "None (static)")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Full (binds to island signals)"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 font-medium text-stone-700") "Use case")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Display text, labels, metadata")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Forms, controls, interactive fragments"))
(tr
(td (~tw :tokens "px-3 py-2 font-medium text-stone-700") "Overhead")
(td (~tw :tokens "px-3 py-2 text-stone-600") "Minimal (DOM swap)")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Parse + eval (small — SX parser is fast)"))))))
(~docs/subsection
:title "Pattern 3: Reactive state directs and transforms hypermedia"
(p
"The deepest marsh pattern. Client signals don't just maintain local UI state — they control the hypermedia system itself: what to fetch, where to put it, and "
(strong "how to interpret it")
".")
(h4
(~tw :tokens "font-semibold mt-4 mb-2")
"3a: Signal-bound hypermedia attributes")
(p
"Hypermedia trigger attributes ("
(code "sx-get")
", "
(code "sx-post")
", "
(code "sx-target")
", "
(code "sx-swap")
") can reference signals. The URL, target, and swap strategy become reactive.")
(~docs/code
:src (highlight
";; A search input whose endpoint depends on reactive state:\n(defisland ~reactive-islands/marshes/smart-search ()\n (let ((mode (signal \"products\"))\n (query (signal \"\")))\n (div\n ;; Mode selector — changes what we're searching\n (select :bind mode\n (option :value \"products\" \"Products\")\n (option :value \"events\" \"Events\")\n (option :value \"posts\" \"Posts\"))\n ;; Search input — endpoint changes reactively\n (input :type \"text\" :bind query\n :sx-get (computed (fn () (str \"/search/\" (deref mode) \"?q=\" (deref query))))\n :sx-trigger \"input changed delay:300ms\"\n :sx-target \"#results\")\n (div :id \"results\"))))"
"lisp"))
(p
"The "
(code "sx-get")
" URL isn't a static string — it's a computed signal. When the mode changes, the next search hits a different endpoint. The hypermedia trigger system reads the signal's current value at trigger time.")
(h4 (~tw :tokens "font-semibold mt-4 mb-2") "3b: Reactive swap transforms")
(p
"A "
(code "marsh-transform")
" function that processes server content "
(em "before")
" it enters the DOM. The transform has access to island signals, so it can reshape the same server response differently based on client state.")
(~docs/code
:src (highlight
";; View mode transforms how server results are displayed:\n(defisland ~reactive-islands/marshes/result-list ()\n (let ((view (signal \"list\"))\n (sort-key (signal \"date\")))\n (div\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (reset! view \"list\")) \"List\")\n (button :on-click (fn (e) (reset! view \"grid\")) \"Grid\")\n (button :on-click (fn (e) (reset! view \"compact\")) \"Compact\"))\n ;; Marsh: server sends a result list; client transforms its rendering\n (marsh :id \"results\"\n :transform (fn (sx-content)\n (case (deref view)\n \"grid\" (wrap-grid sx-content)\n \"compact\" (compact-view sx-content)\n :else sx-content))\n ;; Initial server content\n (div :class \"space-y-2\" \"Loading...\")))))"
"lisp"))
(p
"The server sends the same canonical result list every time. The "
(code ":transform")
" function — a reactive closure over the "
(code "view")
" signal — reshapes it into a grid, compact list, or default list. Change the view signal → existing content is re-transformed without a server round-trip. Fetch new results → they arrive pre-sorted, then the transform applies the current view.")
(h4 (~tw :tokens "font-semibold mt-4 mb-2") "3c: Reactive interpretation")
(p
"The most intimate marsh pattern. Reactive state modifies "
(em "how the hypermedia system itself behaves")
" — not just what's fetched or how it's displayed, but the rules of interpretation.")
(ul
(~tw :tokens "list-disc pl-5 space-y-2 text-stone-600")
(li
(strong "Reactive swap strategy: ")
"A signal controls whether the swap is "
(code "innerHTML")
", "
(code "outerHTML")
", "
(code "morph")
", or "
(code "append")
". The same server response, the same target — but the merge strategy depends on client state. A chat app might "
(code "append")
" new messages normally but "
(code "morph")
" when the user scrolls to a different thread.")
(li
(strong "Reactive content filter: ")
"A signal-driven predicate that filters server content before insertion. The server sends everything; the client's reactive state determines what's visible. A notification feed where the read/unread signal controls whether dismissed items re-appear after a morph.")
(li
(strong "Reactive content rewriting: ")
"Signals that rewrite attributes or classes on incoming server HTML. A dark-mode signal that rewrites "
(code "bg-white")
" to "
(code "bg-stone-900")
" on every server fragment before insertion. An accessibility signal that increases font sizes. The server doesn't need to know about these preferences — the marsh transform applies them at the edge.")
(li
(strong "Reactive routing: ")
"A signal that changes how hypermedia URLs are resolved. A "
(code "locale")
" signal that prepends "
(code "/fr/")
" to every "
(code "sx-get")
" URL. A "
(code "preview-mode")
" signal that reroutes to draft endpoints. The server sees clean, canonical URLs — the client's reactive state performs the translation."))))
(~docs/section
:title "Spec primitives"
:id "primitives"
(p
"Five new constructs, all specced in "
(code ".sx")
" files, bootstrapped to every host.")
(h4 (~tw :tokens "font-semibold mt-4 mb-2") "1. marsh tag")
(~docs/code
:src (highlight
";; In adapter-dom.sx / adapter-html.sx / adapter-sx.sx:\n;;\n;; (marsh :id \"controls\" :transform transform-fn children...)\n;;\n;; Server: renders as <div data-sx-marsh=\"controls\">children HTML</div>\n;; Client: wraps children in reactive evaluation scope\n;; Morph: re-parses incoming SX, evaluates in island scope, replaces DOM\n;;\n;; The :transform is optional. If present, it's called on the parsed SX\n;; before evaluation. The transform has full signal access."
"lisp"))
(h4
(~tw :tokens "font-semibold mt-4 mb-2")
"2. data-sx-signal (morph integration)")
(~docs/code
:src (highlight
";; In engine.sx, morph-children:\n;;\n;; When processing a new element with data-sx-signal=\"name:value\":\n;; 1. Parse the attribute: store-name, signal-value\n;; 2. Look up (use-store store-name) — finds or creates the signal\n;; 3. (reset! signal parsed-value)\n;; 4. Remove the data-sx-signal attribute from DOM (consumed)\n;;\n;; Values are JSON-parsed: \"7\" → 7, '\"hello\"' → \"hello\",\n;; 'true' → true, '{...}' → dict"
"lisp"))
(h4
(~tw :tokens "font-semibold mt-4 mb-2")
"3. Signal-bound hypermedia attributes")
(~docs/code
:src (highlight
";; In orchestration.sx, resolve-trigger-attrs:\n;;\n;; Before issuing a fetch, read sx-get/sx-post/sx-target/sx-swap.\n;; If the value is a signal or computed, deref it at trigger time.\n;;\n;; (define resolve-trigger-url\n;; (fn (el attr)\n;; (let ((val (dom-get-attr el attr)))\n;; (if (signal? val) (deref val) val))))\n;;\n;; This means the URL is evaluated lazily — it reflects the current\n;; signal state at the moment the user acts, not when the DOM was built."
"lisp"))
(h4
(~tw :tokens "font-semibold mt-4 mb-2")
"4. marsh-transform (swap pipeline)")
(~docs/code
:src (highlight
";; In orchestration.sx, process-swap:\n;;\n;; After receiving server HTML and before inserting into target:\n;; 1. Find the target element\n;; 2. If target has data-sx-marsh, find its transform function\n;; 3. Parse server content as SX\n;; 4. Call transform(sx-content) — transform is a reactive closure\n;; 5. Evaluate the transformed SX in the island's signal scope\n;; 6. Replace the marsh's DOM children\n;;\n;; The transform runs inside the island's tracking context,\n;; so computed/effect dependencies are captured automatically.\n;; When a signal the transform reads changes, the marsh re-transforms."
"lisp"))
(h4 (~tw :tokens "font-semibold mt-4 mb-2") "5. sx-on-settle (post-swap hook)")
(~docs/code
:src (highlight
";; In orchestration.sx, after swap completes:\n;;\n;; (define process-settle-hooks\n;; (fn (trigger-el)\n;; (let ((hook (dom-get-attr trigger-el \"sx-on-settle\")))\n;; (when hook\n;; (eval-expr (parse hook) (island-env trigger-el))))))\n;;\n;; The expression is evaluated in the nearest island's environment,\n;; giving it access to signals, stores, and island-local functions."
"lisp")))
(~docs/section
:title "The morph enters the marsh"
:id "morph"
(p
"The morph algorithm already handles three zones: static DOM (full reconciliation), islands (preserve reactive nodes), and lakes (update static content within islands). Marshes add a fourth:")
(div
(~tw :tokens "overflow-x-auto rounded border border-stone-200 mb-4")
(table
(~tw :tokens "w-full text-left text-sm")
(thead
(tr
(~tw :tokens "border-b border-stone-200 bg-stone-100")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Zone")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Marker")
(th
(~tw :tokens "px-3 py-2 font-medium text-stone-600")
"Morph behaviour")))
(tbody
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Static DOM")
(td (~tw :tokens "px-3 py-2 font-mono text-sm text-stone-600") "(none)")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Full morph — attrs, children, text"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Island")
(td
(~tw :tokens "px-3 py-2 font-mono text-sm text-stone-600")
"data-sx-island")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Enter, find lakes/marshes, update them, skip everything else"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Lake")
(td
(~tw :tokens "px-3 py-2 font-mono text-sm text-stone-600")
"data-sx-lake")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Replace static HTML children"))
(tr
(td (~tw :tokens "px-3 py-2 text-stone-700") "Marsh")
(td
(~tw :tokens "px-3 py-2 font-mono text-sm text-stone-600")
"data-sx-marsh")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Parse new content as SX, apply transform, evaluate in island scope, replace DOM")))))
(~docs/code
:src (highlight
";; Updated morph-island-children in engine.sx:\n\n(define morph-island-children\n (fn (old-island new-island)\n (let ((old-lakes (dom-query-all old-island \"[data-sx-lake]\"))\n (new-lakes (dom-query-all new-island \"[data-sx-lake]\"))\n (old-marshes (dom-query-all old-island \"[data-sx-marsh]\"))\n (new-marshes (dom-query-all new-island \"[data-sx-marsh]\")))\n ;; Build lookup maps\n (let ((new-lake-map (index-by-attr new-lakes \"data-sx-lake\"))\n (new-marsh-map (index-by-attr new-marshes \"data-sx-marsh\")))\n ;; Lakes: static DOM swap\n (for-each\n (fn (old-lake)\n (let ((id (dom-get-attr old-lake \"data-sx-lake\"))\n (new-lake (dict-get new-lake-map id)))\n (when new-lake\n (sync-attrs old-lake new-lake)\n (morph-children old-lake new-lake))))\n old-lakes)\n ;; Marshes: parse + evaluate + replace\n (for-each\n (fn (old-marsh)\n (let ((id (dom-get-attr old-marsh \"data-sx-marsh\"))\n (new-marsh (dict-get new-marsh-map id)))\n (when new-marsh\n (morph-marsh old-marsh new-marsh old-island))))\n old-marshes)\n ;; Signal updates from data-sx-signal\n (process-signal-updates new-island)))))"
"lisp"))
(~docs/code
:src (highlight
";; morph-marsh: re-evaluate server content in island scope\n\n(define morph-marsh\n (fn (old-marsh new-marsh island-el)\n (let ((transform (get-marsh-transform old-marsh))\n (new-sx (dom-inner-sx new-marsh))\n (island-env (get-island-env island-el)))\n ;; Apply transform if present\n (let ((transformed (if transform (transform new-sx) new-sx)))\n ;; Dispose old reactive bindings in this marsh\n (dispose-marsh-scope old-marsh)\n ;; Evaluate the SX in island scope — creates new reactive bindings\n (with-marsh-scope old-marsh\n (let ((new-dom (render-to-dom transformed island-env)))\n (dom-replace-children old-marsh new-dom)))))))"
"lisp")))
(~docs/section
:title "Signal lifecycle"
:id "lifecycle"
(p
"Marshes introduce a sub-scope within the island's reactive context. When a marsh is re-evaluated (morph or transform change), its old effects and computeds must be disposed without disturbing the island's own reactive graph.")
(~docs/subsection
:title "Scoping"
(~docs/code
:src (highlight
";; In signals.sx:\n\n(define with-marsh-scope\n (fn (marsh-el body-fn)\n ;; Create a child scope under the current island scope\n ;; All effects/computeds created during body-fn register here\n (let ((parent-scope (current-island-scope))\n (marsh-scope (create-child-scope parent-scope (dom-get-attr marsh-el \"data-sx-marsh\"))))\n (with-scope marsh-scope\n (body-fn)))))\n\n(define dispose-marsh-scope\n (fn (marsh-el)\n ;; Dispose all effects/computeds registered in this marsh's scope\n ;; Parent island scope and sibling marshes are unaffected\n (let ((scope (get-marsh-scope marsh-el)))\n (when scope (dispose-scope scope)))))"
"lisp"))
(p
"The scoping hierarchy: "
(strong "island")
" → "
(strong "marsh")
" → "
(strong "effects/computeds")
". Disposing a marsh disposes its subscope. Disposing an island disposes all its marshes. The signal graph is a tree, not a flat list."))
(~docs/subsection
:title "Reactive transforms"
(p
"When a marsh has a "
(code ":transform")
" function, the transform itself is an effect. It reads signals (via "
(code "deref")
" inside the transform body) and produces transformed SX. When those signals change, the transform re-runs, the marsh re-evaluates, and the DOM updates — all without a server round-trip.")
(p
"The transform effect belongs to the marsh scope, so it's automatically disposed when the marsh is morphed with new content.")))
(~docs/section
:title "Reactive interpretation"
:id "interpretation"
(p
"The deepest marsh pattern isn't about transforming content — it's about transforming the "
(em "rules")
". Reactive state modifies how the hypermedia system itself operates.")
(~docs/subsection
:title "Swap strategy as signal"
(p
"The same server response inserted differently based on client state:")
(~docs/code
:src (highlight
";; Chat app: append messages normally, morph when switching threads\n(defisland ~reactive-islands/marshes/chat ()\n (let ((mode (signal \"live\")))\n (div\n (div :sx-get \"/messages/latest\"\n :sx-trigger \"every 2s\"\n :sx-target \"#messages\"\n :sx-swap (computed (fn ()\n (if (= (deref mode) \"live\") \"beforeend\" \"innerHTML\")))\n (div :id \"messages\")))))"
"lisp"))
(p
"In "
(code "\"live\"")
" mode, new messages append. Switch to thread view — the same polling endpoint now replaces the whole list. The server doesn't change. The client's reactive state changes the "
(em "semantics")
" of the swap."))
(~docs/subsection
:title "URL rewriting as signal"
(p "Reactive state transparently modifies request URLs:")
(~docs/code
:src (highlight
";; Locale prefix — the server sees /fr/products, /en/products, etc.\n;; The author writes /products — the marsh layer prepends the locale.\n(def-store \"locale\" \"en\")\n\n;; In orchestration.sx, resolve-trigger-url:\n(define resolve-trigger-url\n (fn (el attr)\n (let ((raw (dom-get-attr el attr))\n (locale (deref (use-store \"locale\"))))\n (if (and locale (not (starts-with? raw (str \"/\" locale))))\n (str \"/\" locale raw)\n raw))))"
"lisp"))
(p
"Every "
(code "sx-get")
" and "
(code "sx-post")
" URL passes through the resolver. A locale signal, a preview-mode signal, an A/B-test signal — any reactive state can transparently rewrite the request the server sees."))
(~docs/subsection
:title "Content rewriting as signal"
(p
"Incoming server HTML passes through a reactive filter before insertion:")
(~docs/code
:src (highlight
";; Dark mode — rewrites server classes before insertion\n(def-store \"theme\" \"light\")\n\n;; In orchestration.sx, after receiving server HTML:\n(define apply-theme-transform\n (fn (html-str)\n (if (= (deref (use-store \"theme\")) \"dark\")\n (-> html-str\n (replace-all \"bg-white\" \"bg-stone-900\")\n (replace-all \"text-stone-800\" \"text-stone-100\")\n (replace-all \"border-stone-200\" \"border-stone-700\"))\n html-str)))"
"lisp"))
(p
"The server renders canonical light-mode HTML. The client's theme signal rewrites it at the edge. No server-side theme support needed. No separate dark-mode templates. The same document, different interpretation.")
(p
"This is the Hegelian deepening: the reactive state isn't just "
(em "alongside")
" the hypermedia content. It "
(em "constitutes the lens through which the content is perceived")
". The marsh isn't a zone in the DOM — it's a layer in the interpretation pipeline.")))
(~docs/section
:title "Implementation order"
:id "implementation"
(p "Spec-first, bootstrap-second, like everything else.")
(div
(~tw :tokens "overflow-x-auto rounded border border-stone-200 mb-4")
(table
(~tw :tokens "w-full text-left text-sm")
(thead
(tr
(~tw :tokens "border-b border-stone-200 bg-stone-100")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Phase")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Spec files")
(th
(~tw :tokens "px-3 py-2 font-medium text-stone-600")
"What it enables")))
(tbody
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700 font-medium") "1. marsh tag")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"adapter-dom.sx, adapter-html.sx, adapter-sx.sx")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Server-morphable zones with reactive re-evaluation inside islands"))
(tr
(~tw :tokens "border-b border-stone-100")
(td
(~tw :tokens "px-3 py-2 text-stone-700 font-medium")
"2. data-sx-signal")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"engine.sx (morph-island-children)")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Server responses write to named store signals"))
(tr
(~tw :tokens "border-b border-stone-100")
(td
(~tw :tokens "px-3 py-2 text-stone-700 font-medium")
"3. Signal-bound triggers")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"orchestration.sx (resolve-trigger-url)")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"sx-get/sx-post URLs computed from signals"))
(tr
(~tw :tokens "border-b border-stone-100")
(td
(~tw :tokens "px-3 py-2 text-stone-700 font-medium")
"4. marsh-transform")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"engine.sx, signals.sx (marsh scopes)")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Reactive closures reshape server content before insertion"))
(tr
(~tw :tokens "border-b border-stone-100")
(td
(~tw :tokens "px-3 py-2 text-stone-700 font-medium")
"5. sx-on-settle")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"orchestration.sx (process-settle-hooks)")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Post-swap SX evaluation with island scope access"))
(tr
(td
(~tw :tokens "px-3 py-2 text-stone-700 font-medium")
"6. Reactive interpretation")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"orchestration.sx (swap pipeline)")
(td
(~tw :tokens "px-3 py-2 text-stone-600")
"Signal-driven swap strategy, URL rewriting, content transforms")))))
(p
"Each phase is independently deployable. Phase 1-2 are the foundation. Phase 3-5 enable the marsh patterns. Phase 6 is the deep end — reactive interpretation of the hypermedia system itself."))
(~docs/section
:title "Design principles"
:id "principles"
(ol
(~tw :tokens "space-y-3 text-stone-600 list-decimal list-inside")
(li
(strong "Marshes are opt-in per zone.")
" "
(code "lake")
" remains the default for server content inside islands. "
(code "marsh")
" is for the zones that need reactive re-evaluation. Don't use a marsh where a lake suffices.")
(li
(strong "The server doesn't need to know.")
" Marsh transforms, signal-bound URLs, reactive interpretation — these are client-side concerns. The server sends canonical content. The client's reactive state shapes how it arrives. The server remains simple.")
(li
(strong "The signal graph is a tree.")
" Island → marsh → effects. Dispose a marsh, its subscope dies. Dispose an island, all its marshes die. No orphan effects. No memory leaks. No cleanup boilerplate.")
(li
(strong "Transforms are pure where possible.")
" A "
(code ":transform")
" function should be a pure function of (content, signal-values). Side effects belong in "
(code "effect")
" blocks, not transforms. This makes transforms testable, composable, and predictable.")
(li
(strong "Spec-first.")
" Every marsh primitive lives in "
(code ".sx")
" spec files. Bootstrapped to JS and Python. The same marshes will work on future hosts.")))
(~docs/section
:title "The dialectic continues"
:id "dialectic"
(p
"Islands separated client state from server content. Lakes let server content flow through islands. Marshes dissolve the boundary entirely — the same zone is simultaneously server-authored and reactively interpreted.")
(p
"This is the next turn of the Hegelian spiral. The thesis (pure hypermedia) posited the server as sole authority. The antithesis (reactive islands) gave the client its own inner life. The first synthesis (islands + lakes) maintained the boundary between them. The second synthesis (marshes) "
(em "sublates the boundary itself")
".")
(p
"In a marsh, you can't point to a piece of DOM and say \"this is server territory\" or \"this is client territory.\" It's both. The server sent it. The client transformed it. The server can update it. The client will re-transform it. The signal reads the server data. The server data feeds the signal. Subject and substance are one.")
(p
"The practical consequence: an SX application can handle "
(em "any")
" interaction pattern without breaking its architecture. Pure content → hypermedia. Micro-interactions → L1 DOM ops. Reactive UI → islands. Server slots → lakes. And now, for the places where reactivity and hypermedia must truly merge — marshes."))
(~docs/section
:title "Examples"
:id "examples"
(p
(strong "Live interactive islands")
" — click the buttons, inspect the DOM.")
(ol
(~tw :tokens "space-y-1")
(map
(fn
(item)
(li
(a
:href (get item "href")
:sx-get (get item "href")
:sx-target "#sx-content"
:sx-select "#sx-content"
:sx-swap "outerHTML"
:sx-push-url "true"
(~tw :tokens "text-violet-600 hover:underline")
(get item "label"))))
marshes-examples-nav-items)))))

View File

@@ -0,0 +1,25 @@
(defcomp
()
(~docs/page
:title "sx-on-settle"
(p
"After a swap settles, the trigger element's "
(code "sx-on-settle")
" attribute is parsed and evaluated as SX. This runs "
(em "after")
" the content is in the DOM — so you can update reactive state based on what the server returned.")
(p
"Click \"Fetch Item\" to load server content. The response is pure hypermedia. But "
(code "sx-on-settle")
" on the button increments a fetch counter signal "
(em "after")
" the swap. The counter updates reactively.")
(~reactive-islands/marshes/demo-marsh-settle)
(~docs/code
:src (highlight
";; sx-on-settle runs SX after the swap settles\n(defisland ~reactive-islands/marshes/demo-marsh-settle ()\n (let ((count (def-store \"settle-count\" (fn () (signal 0)))))\n (div\n ;; Reactive counter — updates from sx-on-settle\n (span \"Fetched: \" (deref count) \" times\")\n\n ;; Button with sx-on-settle hook\n (button :sx-get \"/sx/(geography.(reactive.(api.settle-data)))\"\n :sx-target \"#settle-result\"\n :sx-swap \"innerHTML\"\n :sx-on-settle \"(swap! (use-store \\\"settle-count\\\") inc)\"\n \"Fetch Item\")\n\n ;; Server content lands here (pure hypermedia)\n (div :id \"settle-result\"))))"
"lisp"))
(p
"The server knows nothing about signals or counters. It returns plain content. The "
(code "sx-on-settle")
" hook is a client-side concern — it runs in the global SX environment with access to all primitives.")))

View File

@@ -0,0 +1,41 @@
(defcomp
()
(~docs/page
:title "Server Writes to Signals"
(p
"Two separate islands share a named store "
(code "\"demo-price\"")
". Island A creates the store and has control buttons. Island B reads it. Signal changes propagate instantly across island boundaries.")
(div
(~tw :tokens "space-y-3")
(~reactive-islands/marshes/demo-marsh-store-writer)
(~reactive-islands/marshes/demo-marsh-store-reader))
(p
(~tw :tokens "mt-3 text-sm text-stone-500")
"The \"Flash Sale\" buttons call "
(code "(reset! price 14.99)")
" — exactly what "
(code "data-sx-signal=\"demo-price:14.99\"")
" does during morph.")
(div
(~tw :tokens "mt-4 rounded border border-stone-200 bg-stone-50 p-3")
(p
(~tw :tokens "text-sm font-medium text-stone-700 mb-2")
"Server endpoint (ready for morph integration):")
(div :id "marsh-flash-target" (~tw :tokens "min-h-[2rem]"))
(button
(~tw :tokens "mt-2 px-3 py-1.5 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:sx-get "/sx/(geography.(reactive.(api.flash-sale)))"
:sx-target "#marsh-flash-target"
:sx-swap "innerHTML"
"Fetch from server"))
(~docs/code
:src (highlight
";; Island A — creates the store, has control buttons\n(defisland ~reactive-islands/marshes/demo-marsh-store-writer ()\n (let ((price (def-store \"demo-price\" (fn () (signal 19.99)))))\n ;; (reset! price 14.99) is what data-sx-signal does during morph\n (button :on-click (fn (e) (reset! price 14.99))\n \"Flash Sale $14.99\")))\n\n;; Island B — reads the same store, different island\n(defisland ~reactive-islands/marshes/demo-marsh-store-reader ()\n (let ((price (def-store \"demo-price\" (fn () (signal 19.99)))))\n (span \"$\" (deref price))))\n\n;; Server returns: data-sx-signal writes to the store during morph\n;; (div :data-sx-signal \"demo-price:14.99\"\n;; (p \"Flash sale! Price updated.\"))"
"lisp"))
(p
"In production, the server response includes "
(code "data-sx-signal=\"demo-price:14.99\"")
". The morph algorithm processes this attribute, calls "
(code "(reset! (use-store \"demo-price\") 14.99)")
", and removes the attribute from the DOM. Every island reading that store updates instantly — fine-grained, no re-render.")))

View File

@@ -0,0 +1,35 @@
(defcomp
()
(~docs/page
:title "Signal-Bound Triggers"
(p
"Inside an island, "
(em "all")
" attributes are reactive — including "
(code "sx-get")
". When an attribute value contains "
(code "deref")
", the DOM adapter wraps it in an effect that re-sets the attribute when signals change.")
(p
"Select a search category. The "
(code "sx-get")
" URL on the search button changes reactively. Click \"Search\" to fetch from the current endpoint. The URL was computed from the "
(code "mode")
" signal at render time and updates whenever the mode changes.")
(~reactive-islands/marshes/demo-marsh-signal-url)
(~docs/code
:src (highlight
";; sx-get URL computed from a signal\n(defisland ~reactive-islands/marshes/demo-marsh-signal-url ()\n (let ((mode (signal \"products\"))\n (query (signal \"\")))\n (div\n ;; Mode selector — changes what we're searching\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (reset! mode \"products\"))\n :class (computed (fn () ...active-class...))\n \"Products\")\n (button :on-click (fn (e) (reset! mode \"events\")) \"Events\")\n (button :on-click (fn (e) (reset! mode \"posts\")) \"Posts\"))\n\n ;; Search button — URL is a computed expression\n (button :sx-get (computed (fn ()\n (str \"/sx/(geography.(reactive.(api.search-\"\n (deref mode) \")))\" \"?q=\" (deref query))))\n :sx-target \"#signal-results\"\n :sx-swap \"innerHTML\"\n \"Search\")\n\n (div :id \"signal-results\"))))"
"lisp"))
(p
"No custom plumbing. The same "
(code "reactive-attr")
" mechanism that makes "
(code ":class")
" reactive also makes "
(code ":sx-get")
" reactive. "
(code "get-verb-info")
" reads "
(code "dom-get-attr")
" at trigger time — it sees the current URL because the effect already updated the DOM attribute.")))

View File

@@ -0,0 +1,15 @@
(defcomp
()
(~docs/page
:title "Reactive View Transform"
(p
"A view-mode signal controls how items are displayed. Click \"Fetch Catalog\" to load items from the server, then toggle the view mode. The "
(em "same")
" data re-renders differently based on client state — no server round-trip for view changes.")
(~reactive-islands/marshes/demo-marsh-view-transform)
(~docs/code
:src (highlight
";; View mode transforms display without refetch\n(defisland ~reactive-islands/marshes/demo-marsh-view-transform ()\n (let ((view (signal \"list\"))\n (items (signal nil)))\n (div\n ;; View toggle\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (reset! view \"list\")) \"List\")\n (button :on-click (fn (e) (reset! view \"grid\")) \"Grid\")\n (button :on-click (fn (e) (reset! view \"compact\")) \"Compact\"))\n\n ;; Fetch from server — stores raw data in signal\n (button :sx-get \"/sx/(geography.(reactive.(api.catalog)))\"\n :sx-target \"#catalog-raw\"\n :sx-swap \"innerHTML\"\n \"Fetch Catalog\")\n\n ;; Raw server content (hidden, used as data source)\n (div :id \"catalog-raw\" :class \"hidden\")\n\n ;; Reactive display — re-renders when view changes\n (div (computed (fn () (render-view (deref view) (deref items))))))))"
"lisp"))
(p
"The view signal doesn't just toggle CSS classes — it fundamentally reshapes the DOM. List view shows description. Grid view arranges in columns. Compact view shows names only. All from the same server data, transformed by client state.")))

View File

@@ -1,4 +1,4 @@
(defcomp ~geography/modules-content ()
(defcomp ()
(~docs/page :title "Modules"
(p (~tw :tokens "text-stone-500 text-sm italic mb-8")
"Declaring what a file needs — for documentation, static analysis, and tooling.")

View File

@@ -0,0 +1,12 @@
(defcomp ()
(div (~tw :tokens "space-y-2")
(provide "scripts" nil
(div (~tw :tokens "rounded-lg p-3 bg-stone-50 border border-stone-200")
(p (~tw :tokens "text-sm text-stone-700")
(emit! "scripts" "analytics.js")
(emit! "scripts" "charts.js")
"Page content renders here. Scripts emitted silently."))
(div (~tw :tokens "rounded-lg p-3 bg-violet-50 border border-violet-200")
(p (~tw :tokens "text-sm text-violet-800 font-semibold") "Emitted scripts:")
(ul (~tw :tokens "text-xs text-stone-600 list-disc pl-5")
(map (fn (s) (li (code s))) (emitted "scripts")))))))

View File

@@ -0,0 +1,12 @@
(defcomp ()
(div (~tw :tokens "space-y-2")
(provide "level" "outer"
(div (~tw :tokens "rounded-lg p-3 bg-stone-50 border border-stone-200")
(p (~tw :tokens "text-sm text-stone-700")
(str "Level: " (context "level")))
(provide "level" "inner"
(div (~tw :tokens "rounded-lg p-3 bg-violet-50 border border-violet-200 ml-4")
(p (~tw :tokens "text-sm text-violet-700")
(str "Level: " (context "level")))))
(p (~tw :tokens "text-sm text-stone-500 mt-1")
(str "Back to: " (context "level")))))))

View File

@@ -0,0 +1,12 @@
;; ---------------------------------------------------------------------------
;; Provide / Context / Emit! — render-time dynamic scope
;; ---------------------------------------------------------------------------
;; ---- Demo components ----
(defcomp ()
(div (~tw :tokens "space-y-2")
(provide "theme" {:primary "violet" :accent "rose"}
(div (~tw :tokens "rounded-lg p-3 bg-violet-50 border border-violet-200")
(p (~tw :tokens "text-sm text-violet-800 font-semibold") "Inside provider: theme.primary = violet")
(p (~tw :tokens "text-xs text-stone-500") "Child reads context value without prop threading.")))
(div (~tw :tokens "rounded-lg p-3 bg-stone-50 border border-stone-200")
(p (~tw :tokens "text-sm text-stone-600") "Outside provider: no theme context."))))

View File

@@ -0,0 +1,9 @@
(defcomp ()
(div (~tw :tokens "space-y-2")
(div (make-spread (~tw :tokens "rounded-lg p-3 bg-rose-50 border border-rose-200"))
(p (~tw :tokens "text-sm text-rose-800 font-semibold") "Spread child styled this div")
(p (~tw :tokens "text-xs text-stone-500") "The spread emitted into the element-attrs provider."))
(let ((card (make-spread (~tw :tokens "rounded-lg p-3 bg-amber-50 border border-amber-200"))))
(div card
(p (~tw :tokens "text-sm text-amber-800 font-semibold") "Stored spread, same mechanism")
(p (~tw :tokens "text-xs text-stone-500") "Bound to a let variable, applied when rendered as child.")))))

View File

@@ -0,0 +1,7 @@
;; ---- Layout helper (reuse from spreads article) ----
(defcomp (&key demo code)
(div (~tw :tokens "grid grid-cols-1 lg:grid-cols-2 gap-4 my-6 items-start")
(div (~tw :tokens "border border-dashed border-stone-300 rounded-lg p-4 bg-stone-50 min-h-[80px]")
demo)
(div (~tw :tokens "not-prose bg-stone-100 rounded-lg p-4 overflow-x-auto")
(pre (~tw :tokens "text-sm leading-relaxed whitespace-pre-wrap break-words") (code code)))))

View File

@@ -0,0 +1,186 @@
;; ---- Page content ----
(defcomp ()
(~docs/page :title "Provide / Context / Emit!"
(p (~tw :tokens "text-stone-500 text-sm italic mb-8")
"Sugar for " (code "scope") " with a value. " (code "provide") " creates a named scope "
"with a value and an accumulator. " (code "context") " reads the value downward. "
(code "emit!") " appends to the accumulator upward. " (code "emitted") " retrieves what was emitted. "
"See "
(a :href "/sx/(geography.(scopes))" (~tw :tokens "text-violet-600 hover:underline") "scopes")
" for the unified primitive.")
;; =====================================================================
;; I. The four primitives
;; =====================================================================
(~docs/section :title "Four primitives" :id "primitives"
(~docs/subsection :title "provide (special form)"
(p (code "provide") " creates a named scope with a value and an empty accumulator. "
"The body expressions execute with the scope active. When the body completes, "
"the scope is popped.")
(~docs/code :src (highlight "(provide name value\n body...)\n\n;; Example: theme context\n(provide \"theme\" {:primary \"violet\"}\n (h1 \"Title\") ;; can read (context \"theme\")\n (p \"Body\")) ;; scope active for all children" "lisp"))
(p (code "provide") " is a special form, not a function — the body is evaluated "
"inside the scope, not before it."))
(~docs/subsection :title "context"
(p "Reads the value from the nearest enclosing " (code "provide") " with the given name. "
"Errors if no provider and no default given.")
(~docs/code :src (highlight "(provide \"theme\" {:primary \"violet\" :font \"serif\"}\n (get (context \"theme\") :primary)) ;; → \"violet\"\n\n;; With default (no error when missing):\n(context \"theme\" {:primary \"stone\"}) ;; → {:primary \"stone\"}" "lisp")))
(~docs/subsection :title "emit!"
(p "Appends a value to the nearest enclosing provider's accumulator. "
"Tolerant: returns nil silently when no provider exists.")
(~docs/code :src (highlight "(provide \"scripts\" nil\n (emit! \"scripts\" \"analytics.js\")\n (emit! \"scripts\" \"charts.js\")\n ;; accumulator now has both scripts\n )\n\n;; Outside any provider — silently does nothing:\n(emit! \"scripts\" \"orphan.js\") ;; → nil, no error" "lisp"))
(p "Tolerance is critical. Spreads emit into " (code "\"element-attrs\"")
" — but a spread might be evaluated in a fragment, a " (code "begin") " block, or a "
(code "map") " call where no element provider exists. "
"Tolerant " (code "emit!") " means these cases silently vanish instead of crashing."))
(~docs/subsection :title "emitted"
(p "Returns the list of values emitted into the nearest provider with the given name. "
"Empty list if no provider.")
(~docs/code :src (highlight "(provide \"scripts\" nil\n (emit! \"scripts\" \"a.js\")\n (emit! \"scripts\" \"b.js\")\n (emitted \"scripts\")) ;; → (\"a.js\" \"b.js\")" "lisp"))))
;; =====================================================================
;; II. Two directions, one mechanism
;; =====================================================================
(~docs/section :title "Two directions, one mechanism" :id "directions"
(p (code "provide") " serves both downward and upward communication through a single scope.")
(~docs/table
:headers (list "Direction" "Read with" "Write with" "Example")
:rows (list
(list "Downward (scope → child)" "context" "provide value" "Theme, config, locale")
(list "Upward (child → scope)" "emitted" "emit!" "Script collection, spread attrs")))
(~geography/provide-demo-example
:demo (~geography/demo-provide-basic)
:code (highlight ";; Downward: theme context\n(provide \"theme\"\n {:primary \"violet\" :accent \"rose\"}\n (h1 :style (str \"color:\"\n (get (context \"theme\") :primary))\n \"Themed heading\")\n (p \"inherits theme context\"))" "lisp"))
(~geography/provide-demo-example
:demo (~geography/demo-emit-collect)
:code (highlight ";; Upward: script accumulation\n(provide \"scripts\" nil\n (div\n (emit! \"scripts\" \"analytics.js\")\n (div\n (emit! \"scripts\" \"charts.js\")\n \"chart\"))\n ;; Collect at the boundary:\n (for-each (fn (s)\n (script :src s))\n (emitted \"scripts\")))" "lisp")))
;; =====================================================================
;; III. How spreads use it
;; =====================================================================
(~docs/section :title "How spreads use provide/emit!" :id "spreads"
(p "Every element rendering function wraps its children in a provider scope "
"named " (code "\"element-attrs\"") ". When the adapter encounters a spread child, "
"it emits the spread's attrs into this scope. After all children render, the "
"element collects and merges the emitted attrs.")
(~geography/provide-demo-example
:demo (~geography/demo-spread-mechanism)
:code (highlight ";; Spread = emit! into element-attrs\n(div (make-spread {:class \"card\"})\n \"hello\")\n\n;; Internally:\n;; 1. div opens provider:\n;; (provide-push! \"element-attrs\" nil)\n;; 2. spread child emits:\n;; (emit! \"element-attrs\"\n;; {:class \"card\"})\n;; 3. div collects + merges:\n;; (emitted \"element-attrs\")\n;; → ({:class \"card\"})\n;; 4. (provide-pop! \"element-attrs\")\n;; Result: <div class=\"card\">hello</div>" "lisp"))
(~docs/subsection :title "Why this matters"
(p "Before the refactor, every intermediate form in the render pipeline — "
"fragments, " (code "let") ", " (code "begin") ", " (code "map") ", "
(code "for-each") ", " (code "when") ", " (code "cond") ", component children — "
"needed an explicit " (code "(filter (fn (r) (not (spread? r))) ...)") " to strip "
"spread values from rendered output. Over 25 such filters existed across the four adapters.")
(p "With provide/emit!, all of these disappear. Spreads emit into the nearest element's "
"scope regardless of how many layers of control flow they pass through. Non-element "
"contexts have no provider, so " (code "emit!") " is a silent no-op.")))
;; =====================================================================
;; IV. Nested scoping
;; =====================================================================
(~docs/section :title "Nested scoping" :id "nesting"
(p "Providers stack. Each " (code "provide") " pushes onto a per-name stack; "
"the closest one wins. This gives lexical-style scoping at render time.")
(~geography/provide-demo-example
:demo (~geography/demo-nested-provide)
:code (highlight "(provide \"level\" \"outer\"\n (context \"level\") ;; → \"outer\"\n (provide \"level\" \"inner\"\n (context \"level\")) ;; → \"inner\"\n (context \"level\")) ;; → \"outer\" again" "lisp"))
(p "For " (code "emit!") ", this means emissions go to the " (em "nearest") " provider. "
"A spread inside a nested element emits to that element, not an ancestor.")
(~docs/code :src (highlight ";; Nested elements = nested providers\n(div ;; provider A\n (span ;; provider B\n (make-spread {:class \"inner\"})) ;; emits to B\n (make-spread {:class \"outer\"})) ;; emits to A\n;; → <div class=\"outer\"><span class=\"inner\"></span></div>" "lisp")))
;; =====================================================================
;; V. Across all adapters
;; =====================================================================
(~docs/section :title "Across all adapters" :id "adapters"
(p "The provide/emit! mechanism works identically across all four rendering adapters. "
"The element rendering pattern is the same; only the output format differs.")
(~docs/table
:headers (list "Adapter" "Element render" "Spread dispatch")
:rows (list
(list "HTML (server)" "provide-push! → render children → merge emitted → provide-pop!" "(emit! \"element-attrs\" (spread-attrs expr)) → \"\"")
(list "Async (server)" "Same pattern, with await on child rendering" "Same dispatch")
(list "SX wire (aser)" "provide-push! → serialize children → merge emitted as :key attrs → provide-pop!" "(emit! \"element-attrs\" (spread-attrs expr)) → nil")
(list "DOM (browser)" "provide-push! → reduce children → merge emitted onto DOM element → provide-pop!" "emit! + keep value for reactive-spread detection")))
(~docs/subsection :title "DOM adapter: reactive-spread preserved"
(p "In the DOM adapter, spread children inside islands are still checked individually "
"for signal dependencies. " (code "reactive-spread") " tracks signal deps and "
"surgically updates attributes when signals change. The static path uses provide/emit!; "
"the reactive path wraps it in an effect.")
(p "See the "
(a :href "/sx/(geography.(spreads))" (~tw :tokens "text-violet-600 hover:underline") "spreads article")
" for reactive-spread details.")))
;; =====================================================================
;; VI. Comparison with collect!
;; =====================================================================
(~docs/section :title "Comparison with collect! / collected" :id "comparison"
(~docs/table
:headers (list "" "provide / emit!" "collect! / collected")
:rows (list
(list "Scope" "Lexical (nearest enclosing provide)" "Global (render-wide)")
(list "Deduplication" "None — every emit! appends" "Automatic (same value skipped)")
(list "Multiple scopes" "Yes — nested provides shadow" "No — single global bucket per name")
(list "Downward data" "Yes (context)" "No")
(list "Used by" "Spreads (element-attrs)" "CSSX rule accumulation")))
(p (code "collect!") " remains the right tool for CSS rule accumulation — deduplication "
"matters there, and rules need to reach the layout root regardless of nesting depth. "
(code "emit!") " is right for spread attrs — no dedup needed, and each element only "
"wants attrs from its direct children."))
;; =====================================================================
;; VII. Platform implementation
;; =====================================================================
(~docs/section :title "Platform implementation" :id "platform"
(p (code "provide") " is sugar for " (code "scope") ". At the platform level, "
(code "provide-push!") " and " (code "provide-pop!") " are aliases for "
(code "scope-push!") " and " (code "scope-pop!") ". All operations work on a unified "
(code "_scope_stacks") " data structure.")
(~docs/table
:headers (list "Platform primitive" "Purpose")
:rows (list
(list "scope-push!(name, value)" "Push a new scope with value and empty accumulator")
(list "scope-pop!(name)" "Pop the most recent scope")
(list "context(name, ...default)" "Read value from nearest scope (error if missing and no default)")
(list "emit!(name, value)" "Append to nearest scope's accumulator (tolerant: no-op if missing)")
(list "emitted(name)" "Return accumulated values from nearest scope")))
(p (code "provide") " is a special form in "
(a :href "/sx/(language.(spec.(explore.evaluator)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm") "eval.sx")
" — it calls " (code "scope-push!") ", evaluates the body, "
"then calls " (code "scope-pop!") ". See "
(a :href "/sx/(geography.(scopes))" (~tw :tokens "text-violet-600 hover:underline") "scopes")
" for the full unified platform.")
(~docs/note
(p (strong "Spec explorer: ") "See the provide/emit! primitives in "
(a :href "/sx/(language.(spec.(explore.boundary)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm") "boundary.sx explorer")
". The " (code "provide") " special form is in "
(a :href "/sx/(language.(spec.(explore.evaluator)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm") "eval.sx explorer")
". Element rendering with provide/emit! is visible in "
(a :href "/sx/(language.(spec.(explore.adapter-html)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm") "adapter-html")
" and "
(a :href "/sx/(language.(spec.(explore.adapter-async)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm") "adapter-async")
".")))))

View File

@@ -0,0 +1,53 @@
;; ---------------------------------------------------------------------------
;; L6: App Shell — client-side mini-app
;; ---------------------------------------------------------------------------
(defisland ()
(let ((route (signal "home"))
(count (signal 0)))
(div (~tw :tokens "rounded border border-violet-200 bg-violet-50 p-4 my-4")
;; Client-side nav bar
(nav (~tw :tokens "flex gap-1 border-b border-stone-200 pb-2 mb-3")
(button :class (str "px-2 py-1 text-xs rounded "
(if (= (deref route) "home")
"bg-violet-600 text-white font-medium"
"bg-stone-200 text-stone-600 hover:bg-stone-300"))
:on-click (fn (e) (reset! route "home"))
"Home")
(button :class (str "px-2 py-1 text-xs rounded "
(if (= (deref route) "counter")
"bg-violet-600 text-white font-medium"
"bg-stone-200 text-stone-600 hover:bg-stone-300"))
:on-click (fn (e) (reset! route "counter"))
"Counter")
(button :class (str "px-2 py-1 text-xs rounded "
(if (= (deref route) "about")
"bg-violet-600 text-white font-medium"
"bg-stone-200 text-stone-600 hover:bg-stone-300"))
:on-click (fn (e) (reset! route "about"))
"About"))
;; Reactive view switching
(div (~tw :tokens "min-h-24")
(if (= (deref route) "home")
(div (~tw :tokens "p-3")
(h3 (~tw :tokens "font-bold text-stone-800 mb-1") "Home")
(p (~tw :tokens "text-sm text-stone-600")
"Client-side rendered. No server round-trip for navigation."))
(if (= (deref route) "counter")
(div (~tw :tokens "p-3")
(h3 (~tw :tokens "font-bold text-stone-800 mb-2") "Counter")
(div (~tw :tokens "flex items-center gap-3")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (swap! count dec))
"")
(span (~tw :tokens "text-2xl font-bold text-violet-900 font-mono w-12 text-center")
(deref count))
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (swap! count inc))
"+"))
(p (~tw :tokens "text-xs text-stone-400 mt-2")
"State persists across view switches — signals live in closures."))
(div (~tw :tokens "p-3")
(h3 (~tw :tokens "font-bold text-stone-800 mb-1") "About")
(p (~tw :tokens "text-sm text-stone-600")
"This mini-app is entirely client-rendered. The server provides only "
"the component definition and mount point — zero HTML content."))))))))

View File

@@ -0,0 +1,72 @@
;; ---------------------------------------------------------------------------
;; L3: Commands — counter with undo/redo
;; ---------------------------------------------------------------------------
(defisland ()
(let ((value (signal 0))
(history (signal (list)))
(future (signal (list)))
(undo-class (signal "bg-stone-200 text-stone-400 cursor-not-allowed"))
(redo-class (signal "bg-stone-200 text-stone-400 cursor-not-allowed")))
(let ((do-cmd (fn (new-val)
(let ((old-val (deref value))
(old-hist (deref history)))
(do
(reset! history (cons old-val old-hist))
(reset! future (list))
(reset! value new-val)))))
(undo (fn ()
(let ((h (deref history)))
(when (> (len h) 0)
(let ((prev (first h))
(old-val (deref value))
(f (deref future)))
(do
(reset! future (cons old-val f))
(reset! history (rest h))
(reset! value prev)))))))
(redo (fn ()
(let ((f (deref future)))
(when (> (len f) 0)
(let ((next-val (first f))
(old-val (deref value))
(h (deref history)))
(do
(reset! history (cons old-val h))
(reset! future (rest f))
(reset! value next-val)))))))
(_e1 (effect (fn ()
(reset! undo-class
(if (> (len (deref history)) 0)
"bg-stone-600 text-white hover:bg-stone-700"
"bg-stone-200 text-stone-400 cursor-not-allowed")))))
(_e2 (effect (fn ()
(reset! redo-class
(if (> (len (deref future)) 0)
"bg-stone-600 text-white hover:bg-stone-700"
"bg-stone-200 text-stone-400 cursor-not-allowed"))))))
(div (~tw :tokens "rounded border border-violet-200 bg-violet-50 p-4 my-4")
(div (~tw :tokens "flex items-center gap-3 mb-3")
(span (~tw :tokens "text-3xl font-bold text-violet-900 font-mono w-20 text-center")
(deref value))
(div (~tw :tokens "flex gap-1")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (do-cmd (+ (deref value) 1)))
"+1")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (do-cmd (+ (deref value) 5)))
"+5")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (do-cmd (* (deref value) 2)))
"×2")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (do-cmd 0))
"0")))
(div (~tw :tokens "flex items-center gap-3")
(button :class (str "px-3 py-1 rounded text-sm font-medium " (deref undo-class))
:on-click (fn (e) (undo))
"Undo")
(button :class (str "px-3 py-1 rounded text-sm font-medium " (deref redo-class))
:on-click (fn (e) (redo))
"Redo")
(span (~tw :tokens "text-xs text-stone-400 font-mono")
"history: " (deref (computed (fn () (len (deref history)))))))))))

View File

@@ -0,0 +1,34 @@
;; ---------------------------------------------------------------------------
;; L1: Foreign — canvas drawing via host-call
;; ---------------------------------------------------------------------------
(defisland ()
(let ((canvas-ref (dict "current" nil))
(color (signal "#8b5cf6"))
(count (signal 0)))
(let ((_eff (effect (fn ()
(let ((el (get canvas-ref "current"))
(n (deref count))
(c (deref color)))
(when el
(let ((ctx (host-call el "getContext" "2d")))
(host-call ctx "clearRect" 0 0 280 160)
(host-set! ctx "fillStyle" c)
(host-call ctx "fillRect" 10 10 (min (* n 25) 260) (min (* n 18) 140)))))))))
(div (~tw :tokens "rounded border border-violet-200 bg-violet-50 p-4 my-4")
(canvas :ref canvas-ref :width "280" :height "160"
(~tw :tokens "border border-stone-300 rounded bg-white block mb-3"))
(div (~tw :tokens "flex items-center gap-3")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (swap! count inc))
"Add")
(select :bind color
(~tw :tokens "px-2 py-1 rounded border border-stone-300 text-sm")
(option :value "#8b5cf6" "Violet")
(option :value "#3b82f6" "Blue")
(option :value "#ef4444" "Red")
(option :value "#22c55e" "Green"))
(button (~tw :tokens "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400")
:on-click (fn (e) (reset! count 0))
"Clear")
(span (~tw :tokens "text-sm text-stone-500")
(deref count) " squares"))))))

View File

@@ -0,0 +1,36 @@
;; ---------------------------------------------------------------------------
;; L5: Keyed Lists — reorderable items
;; ---------------------------------------------------------------------------
(defisland ()
(let ((next-id (signal 1))
(items (signal (list)))
(colors (list "violet" "blue" "green" "amber" "red" "stone"))
(add-item (fn (e)
(let ((id (deref next-id))
(old (deref items)))
(do
(reset! items (append old (dict "id" id
"text" (str "Item " id)
"color" (nth colors (mod (- id 1) 6)))))
(reset! next-id (+ id 1))))))
(remove-item (fn (id)
(reset! items (filter (fn (item) (not (= (get item "id") id))) (deref items))))))
(div (~tw :tokens "rounded border border-violet-200 bg-violet-50 p-4 my-4")
(div (~tw :tokens "flex items-center gap-2 mb-3")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click add-item
"Add Item")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (reset! items (reverse (deref items))))
"Reverse")
(span (~tw :tokens "text-sm text-stone-500")
(deref (computed (fn () (len (deref items))))) " items"))
(ul (~tw :tokens "space-y-1")
(map (fn (item)
(li :key (str (get item "id"))
:class (str "flex items-center justify-between rounded px-3 py-2 text-sm bg-" (get item "color") "-100 text-" (get item "color") "-800")
(span (get item "text"))
(button (~tw :tokens "text-stone-400 hover:text-red-500 text-xs ml-2")
:on-click (fn (e) (remove-item (get item "id")))
"✕")))
(deref items))))))

View File

@@ -0,0 +1,37 @@
;; ---------------------------------------------------------------------------
;; L4: Render Loop — bouncing ball
;; ---------------------------------------------------------------------------
(defisland ()
(let ((running (signal true))
(x (signal 0))
(dir (signal 1))
(frames (signal 0)))
(let ((_eff (effect (fn ()
(when (deref running)
(let ((id (set-interval
(fn ()
(batch (fn ()
(swap! frames inc)
(when (>= (deref x) 230) (reset! dir -1))
(when (<= (deref x) 0) (reset! dir 1))
(swap! x (fn (v) (+ v (* (deref dir) 3)))))))
16)))
(fn () (clear-interval id))))))))
(div (~tw :tokens "rounded border border-violet-200 bg-violet-50 p-4 my-4")
(div (~tw :tokens "relative h-12 bg-white rounded border border-stone-200 mb-3 overflow-hidden")
(div (~tw :tokens "absolute top-1 w-10 h-10 rounded-full bg-violet-500 transition-none")
:style (str "left:" (deref x) "px")))
(div (~tw :tokens "flex items-center gap-3")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (swap! running not))
(if (deref running) "Pause" "Play"))
(button (~tw :tokens "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400")
:on-click (fn (e)
(batch (fn ()
(reset! running false)
(reset! x 0)
(reset! dir 1)
(reset! frames 0))))
"Reset")
(span (~tw :tokens "text-sm text-stone-500 font-mono")
"frames: " (deref frames)))))))

View File

@@ -0,0 +1,40 @@
;; ---------------------------------------------------------------------------
;; L2: State Machine — traffic light
;; ---------------------------------------------------------------------------
(defisland ()
(let ((state (signal "red"))
(transitions (dict "red" "green" "green" "yellow" "yellow" "red"))
(auto (signal false)))
(let ((_eff (effect (fn ()
(when (deref auto)
(let ((delay (if (= (deref state) "yellow") 1000 2500)))
(let ((id (set-timeout
(fn () (reset! state (get transitions (deref state))))
delay)))
(fn () (clear-timeout id)))))))))
(div (~tw :tokens "rounded border border-violet-200 bg-violet-50 p-4 my-4")
(div (~tw :tokens "flex items-center gap-6")
;; Traffic light
(div (~tw :tokens "flex flex-col gap-2 p-3 bg-stone-800 rounded-lg")
(div :class (str "w-10 h-10 rounded-full transition-colors "
(if (= (deref state) "red") "bg-red-500 shadow-lg shadow-red-500/50" "bg-red-900/30")))
(div :class (str "w-10 h-10 rounded-full transition-colors "
(if (= (deref state) "yellow") "bg-yellow-400 shadow-lg shadow-yellow-400/50" "bg-yellow-900/30")))
(div :class (str "w-10 h-10 rounded-full transition-colors "
(if (= (deref state) "green") "bg-green-500 shadow-lg shadow-green-500/50" "bg-green-900/30"))))
;; Controls
(div (~tw :tokens "space-y-3")
(div (~tw :tokens "flex items-center gap-2")
(span (~tw :tokens "text-sm font-medium text-stone-700") "State:")
(span (~tw :tokens "text-sm font-mono text-violet-700") (deref state)))
(div (~tw :tokens "flex items-center gap-2")
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e)
(reset! state (get transitions (deref state))))
"Next")
(button :class (str "px-3 py-1 rounded text-sm font-medium "
(if (deref auto)
"bg-amber-500 text-white hover:bg-amber-600"
"bg-stone-300 text-stone-700 hover:bg-stone-400"))
:on-click (fn (e) (swap! auto not))
(if (deref auto) "Auto: ON" "Auto: OFF")))))))))

View File

@@ -0,0 +1,39 @@
;; ===========================================================================
;; Live demo islands
;; ===========================================================================
;; ---------------------------------------------------------------------------
;; L0: Ref — timer ID in closure, DOM handle in dict
;; ---------------------------------------------------------------------------
(defisland ()
(let ((input-ref (dict "current" nil))
(ticks (signal 0))
(running (signal false))
(last-value (signal "")))
(let ((_eff (effect (fn ()
(when (deref running)
(let ((id (set-interval (fn () (swap! ticks inc)) 500)))
(fn () (clear-interval id))))))))
(div (~tw :tokens "rounded border border-violet-200 bg-violet-50 p-4 my-4")
(p (~tw :tokens "text-xs font-semibold text-stone-500 mb-2")
"Timer — interval ID captured in effect closure")
(div (~tw :tokens "flex items-center gap-3 mb-4")
(span (~tw :tokens "text-2xl font-bold text-violet-900 font-mono w-16 text-center")
(deref ticks))
(button (~tw :tokens "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700")
:on-click (fn (e) (swap! running not))
(if (deref running) "Stop" "Start"))
(button (~tw :tokens "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400")
:on-click (fn (e) (do (reset! running false) (reset! ticks 0)))
"Reset"))
(p (~tw :tokens "text-xs font-semibold text-stone-500 mb-2")
"DOM handle — element ref in dict")
(div (~tw :tokens "flex items-center gap-3")
(input :ref input-ref :type "text" :placeholder "Type something…"
(~tw :tokens "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48"))
(button (~tw :tokens "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400")
:on-click (fn (e)
(reset! last-value (dom-get-prop (get input-ref "current") "value")))
"Read")
(when (not (= (deref last-value) ""))
(span (~tw :tokens "text-sm text-stone-600 font-mono")
"\"" (deref last-value) "\"")))))))

View File

@@ -0,0 +1,32 @@
;; ---------------------------------------------------------------------------
;; L6: App Shell
;; ---------------------------------------------------------------------------
(defcomp ()
(~docs/page :title "Layer 6: Client-First App Shell"
(p (~tw :tokens "text-stone-500 text-sm italic mb-8")
"Skip SSR entirely for canvas-heavy apps. Server returns a minimal HTML shell; "
"all rendering happens client-side.")
(~docs/section :title "Live Demo" :id "demo"
(p "A mini single-page app rendered entirely client-side. Navigation switches views via a " (code "route") " signal — no server round-trips. Counter state persists across view switches because signals live in closures, not the DOM.")
(~reactive-runtime/demo-app-shell)
(~docs/code :src (highlight "(defisland ~demo-app-shell ()\n (let ((route (signal \"home\"))\n (count (signal 0)))\n ;; Client-side nav\n (nav\n (button :on-click (fn (e) (reset! route \"home\"))\n :class (str ... (if (= (deref route) \"home\") \"active\" \"inactive\"))\n \"Home\")\n ...)\n ;; Reactive view switching\n (cond\n (= (deref route) \"home\")\n (div (p \"Client-rendered. No server round-trip.\"))\n (= (deref route) \"counter\")\n (div (span (deref count)) ...)\n (= (deref route) \"about\")\n (div (p \"Entirely client-rendered.\")))))" "lisp"))
(p "This island IS a mini app shell. The " (code "make-app") " function generalizes the pattern: server returns " (code "<div id=\"sx-app-root\">") " + SX loader, " (code "app-boot") " mounts the entry component, " (code "app-navigate!") " switches routes — all using existing " (code "sx-mount") " and signals."))
(~docs/section :title "API" :id "app-api"
(~docs/code :src (highlight
"(define my-app (make-app\n {:entry ~drawing-app\n :stores (list canvas-state tool-state)\n :routes {\"/\" ~drawing-app\n \"/gallery\" ~gallery-view}\n :head (list (link :rel \"stylesheet\" :href \"/static/app.css\"))}))\n\n;; Server: generate minimal HTML shell\n(app-shell-html my-app)\n;; => <!doctype html>...<div id=\"sx-app-root\">...</div>...\n\n;; Client: boot the app into the root element\n(app-boot my-app (dom-query \"#sx-app-root\"))\n\n;; Client: navigate between routes\n(app-navigate! my-app \"/gallery\")"
"lisp")))
(~docs/section :title "What the Server Returns" :id "app-shell"
(~docs/code :src (highlight
"<!doctype html>\n<html>\n<head>\n <link rel=\"stylesheet\" href=\"/static/app.css\">\n <script src=\"/static/scripts/sx-browser.js\"></script>\n</head>\n<body>\n <div id=\"sx-app-root\"></div>\n <script>\n SX.boot(function(sx) {\n sx.appBoot(/* app config */)\n })\n </script>\n</body>\n</html>"
"html"))
(p "The shell contains no rendered content — just the mount point and loader. "
"Uses existing " (code "sx-mount") " and " (code "sx-hydrate-islands") " from boot.sx.")
(p "~330 lines. Orchestrates all other layers."))))

View File

@@ -0,0 +1,37 @@
;; ---------------------------------------------------------------------------
;; L3: Commands with History
;; ---------------------------------------------------------------------------
(defcomp ()
(~docs/page :title "Layer 3: Commands with History"
(p (~tw :tokens "text-stone-500 text-sm italic mb-8")
"Command pattern built into signal stores. Commands are s-expressions "
"in a history stack — trivially serializable, with transaction grouping for drag operations.")
(~docs/section :title "Live Demo" :id "demo"
(p "A counter with full undo/redo. Each operation pushes the previous state onto the undo stack. Undo pops from undo and pushes to redo. All state is reactive — buttons disable when their stack is empty.")
(~reactive-runtime/demo-commands)
(~docs/code :src (highlight "(defisland ~demo-commands ()\n (let ((value (signal 0))\n (undo-stack (signal (list)))\n (redo-stack (signal (list)))\n (do-cmd (fn (new-val)\n (batch (fn ()\n (swap! undo-stack (fn (s) (cons (deref value) s)))\n (reset! redo-stack (list))\n (reset! value new-val)))))\n (undo (fn ()\n (when (> (len (deref undo-stack)) 0)\n (batch (fn ()\n (let ((prev (first (deref undo-stack))))\n (swap! redo-stack (fn (r) (cons (deref value) r)))\n (reset! value prev)\n (swap! undo-stack rest)))))))\n (redo (fn () ...)))\n (span (deref value))\n (button :on-click (fn (e) (do-cmd (+ (deref value) 1))) \"+1\")\n (button :on-click (fn (e) (undo)) \"Undo\")))" "lisp"))
(p "Three signals and two functions. " (code "batch") " groups the three writes (push old, clear redo, set new) into one notification pass. The stacks use " (code "cons") "/" (code "first") "/" (code "rest") " — standard list operations. " (code "make-command-store") " wraps this pattern and adds transaction grouping."))
(~docs/section :title "API" :id "commands-api"
(~docs/code :src (highlight
"(define canvas-state (make-command-store\n {:initial {:elements (list) :selection nil}\n :commands {:add-element (fn (state el)\n (assoc state :elements\n (append (get state :elements) (list el))))\n :move-element (fn (state id dx dy) ...)}\n :max-history 100}))\n\n;; Dispatch commands\n(cmd-dispatch! canvas-state :add-element rect-1)\n\n;; Undo / redo\n(cmd-undo! canvas-state)\n(cmd-redo! canvas-state)\n\n;; Reactive predicates\n(deref (cmd-can-undo? canvas-state)) ;; => true\n(deref (cmd-can-redo? canvas-state)) ;; => false\n\n;; Transaction grouping — collapses into single undo entry\n(cmd-group-start! canvas-state \"drag\")\n(cmd-dispatch! canvas-state :move-element id 1 0)\n(cmd-dispatch! canvas-state :move-element id 1 0)\n(cmd-dispatch! canvas-state :move-element id 1 0)\n(cmd-group-end! canvas-state)\n;; One undo reverses all three moves"
"lisp")))
(~docs/section :title "Internals" :id "commands-internals"
(p "A command store wraps three signals and a ref:")
(ul (~tw :tokens "list-disc pl-6 mb-4 space-y-1")
(li (strong "state") " signal — current application state")
(li (strong "undo-stack") " signal — list of previous states")
(li (strong "redo-stack") " signal — list of undone states")
(li (strong "group") " ref — current transaction label (nil when not grouping)"))
(p (code "cmd-dispatch!") " applies the command function to the current state, "
"pushes the old state onto the undo stack, and clears the redo stack. "
"During a group, intermediate states are collapsed so only the pre-group "
"state appears on the undo stack.")
(p "~320 lines. The most complex layer, but pure signals + dicts."))))

View File

@@ -0,0 +1,36 @@
;; ---------------------------------------------------------------------------
;; L1: Foreign
;; ---------------------------------------------------------------------------
(defcomp ()
(~docs/page :title "Layer 1: Foreign (Host API Interop)"
(p (~tw :tokens "text-stone-500 text-sm italic mb-8")
"Clean boundary for calling host APIs — Canvas, WebGL, WebAudio, any browser API — "
"from SX code. Function factories with automatic kebab-to-camelCase conversion.")
(~docs/section :title "Live Demo" :id "demo"
(p "Click the canvas to place colored rectangles. Uses " (code "host-call") " for canvas 2D context method calls and " (code "host-set!") " for property writes — the raw primitives that " (code "foreign-method") " and " (code "foreign-prop-setter") " will wrap.")
(~reactive-runtime/demo-foreign)
(~docs/code :src (highlight "(defisland ~demo-foreign ()\n (let ((canvas-ref (dict \"current\" nil))\n (color (signal \"#8b5cf6\"))\n (rects (signal (list))))\n ;; Draw effect — re-runs when rects changes\n (effect (fn ()\n (let ((el (get canvas-ref \"current\")))\n (when el\n (let ((ctx (host-call el \"getContext\" \"2d\")))\n (host-call ctx \"clearRect\" 0 0 280 160)\n (for-each (fn (r)\n (host-set! ctx \"fillStyle\" (get r \"c\"))\n (host-call ctx \"fillRect\" (get r \"x\") (get r \"y\") 20 20))\n (deref rects)))))))\n (canvas :ref canvas-ref :on-click (fn (e) ...)\n :width \"280\" :height \"160\")))" "lisp"))
(p "Every canvas operation is a raw " (code "host-call") " or " (code "host-set!") ". The foreign FFI layer wraps these into named SX functions: " (code "(fill-rect ctx 0 0 280 160)") " instead of " (code "(host-call ctx \"fillRect\" 0 0 280 160)") "."))
(~docs/section :title "API" :id "foreign-api"
(~docs/code :src (highlight
";; Function factories — each returns a reusable function\n(define fill-rect (foreign-method \"fill-rect\"))\n(define set-fill-style! (foreign-prop-setter \"fill-style\"))\n(define get-width (foreign-prop-getter \"width\"))\n\n;; Usage — clean SX calls, no string method names\n(let ((ctx (host-call canvas \"getContext\" \"2d\")))\n (set-fill-style! ctx \"red\")\n (fill-rect ctx 0 0 (get-width canvas) 100))"
"lisp"))
(p "Three factory functions, one helper:")
(ul (~tw :tokens "list-disc pl-6 mb-4 space-y-1")
(li (code "foreign-method") " — wraps " (code "host-call") " with kebab→camel conversion")
(li (code "foreign-prop-getter") " — wraps " (code "host-get"))
(li (code "foreign-prop-setter") " — wraps " (code "host-set!"))
(li (code "kebab->camel") " — " (code "\"fill-rect\"") " → " (code "\"fillRect\""))))
(~docs/section :title "Implementation" :id "foreign-impl"
(~docs/code :src (highlight
"(define kebab->camel (fn (s)\n ;; \"fill-rect\" → \"fillRect\", \"font-size\" → \"fontSize\"\n (let ((parts (split s \"-\"))\n (first-part (first parts))\n (rest-parts (rest parts)))\n (str first-part\n (join \"\" (map (fn (p)\n (str (upper (slice p 0 1)) (slice p 1))) rest-parts))))))\n\n(define foreign-method (fn (method-name)\n (let ((camel (kebab->camel method-name)))\n (fn (obj &rest args)\n (apply host-call (concat (list obj camel) args))))))\n\n(define foreign-prop-getter (fn (prop-name)\n (let ((camel (kebab->camel prop-name)))\n (fn (obj) (host-get obj camel)))))\n\n(define foreign-prop-setter (fn (prop-name)\n (let ((camel (kebab->camel prop-name)))\n (fn (obj val) (host-set! obj camel val)))))"
"lisp"))
(p "~100 lines. Uses existing host FFI primitives — no new platform interface needed."))))

View File

@@ -0,0 +1,95 @@
;; Reactive Application Runtime — 7 Feature Layers
;; Zero new platform primitives. All functions composing existing signals + DOM ops.
;; Lives under Applications: /(applications.(reactive-runtime))
;; ---------------------------------------------------------------------------
;; Overview page
;; ---------------------------------------------------------------------------
(defcomp ()
(~docs/page :title "Reactive Application Runtime"
(p (~tw :tokens "text-stone-500 text-sm italic mb-8")
"Seven feature layers that take SX from reactive documents to a full application runtime. "
"Every layer is a pure SX function composing existing primitives — zero new CEK frames, "
"zero new platform primitives. The existing platform interface is sufficient "
"for any application pattern.")
;; =====================================================================
;; Motivation
;; =====================================================================
(~docs/section :title "Motivation" :id "motivation"
(p "SX has signals, computed, effects, batch, islands, lakes, stores, and bridge events — "
"sufficient for reactive documents (forms, toggles, counters, live search). "
"But complex client-heavy apps (drawing tools, editors, games) need structure on top:")
(table (~tw :tokens "w-full mb-6 text-sm")
(thead
(tr (th (~tw :tokens "text-left p-2") "Have") (th (~tw :tokens "text-left p-2") "Need")))
(tbody
(tr (td (~tw :tokens "p-2") "Signal holds a value") (td (~tw :tokens "p-2") "Ref holds a value " (em "without") " reactivity"))
(tr (td (~tw :tokens "p-2") "Effect runs on change") (td (~tw :tokens "p-2") "Loop runs " (em "continuously")))
(tr (td (~tw :tokens "p-2") "Store shares state") (td (~tw :tokens "p-2") "Machine manages " (em "modal") " state"))
(tr (td (~tw :tokens "p-2") "reset!/swap! update") (td (~tw :tokens "p-2") "Commands update " (em "with history")))
(tr (td (~tw :tokens "p-2") "DOM rendering") (td (~tw :tokens "p-2") "Foreign calls " (em "any host API")))
(tr (td (~tw :tokens "p-2") "Server-first hydration") (td (~tw :tokens "p-2") "Client-first " (em "app shell"))))))
;; =====================================================================
;; The Seven Layers
;; =====================================================================
(~docs/section :title "The Seven Layers" :id "layers"
(table (~tw :tokens "w-full mb-6 text-sm")
(thead
(tr (th (~tw :tokens "text-left p-2") "Layer") (th (~tw :tokens "text-left p-2") "What") (th (~tw :tokens "text-left p-2") "Builds On")))
(tbody
(tr (td (~tw :tokens "p-2 font-mono") "L0") (td (~tw :tokens "p-2") "Ref — mutable box without reactivity") (td (~tw :tokens "p-2") "register-in-scope"))
(tr (td (~tw :tokens "p-2 font-mono") "L1") (td (~tw :tokens "p-2") "Foreign — host API interop") (td (~tw :tokens "p-2") "host-call, host-get, host-set!"))
(tr (td (~tw :tokens "p-2 font-mono") "L2") (td (~tw :tokens "p-2") "State machines — modal state as signal") (td (~tw :tokens "p-2") "signal, deref, reset!"))
(tr (td (~tw :tokens "p-2 font-mono") "L3") (td (~tw :tokens "p-2") "Commands — undo/redo signal stores") (td (~tw :tokens "p-2") "signal, computed, L0"))
(tr (td (~tw :tokens "p-2 font-mono") "L4") (td (~tw :tokens "p-2") "Render loop — continuous rAF") (td (~tw :tokens "p-2") "L0, request-animation-frame"))
(tr (td (~tw :tokens "p-2 font-mono") "L5") (td (~tw :tokens "p-2") "Keyed lists — explicit key reconciliation") (td (~tw :tokens "p-2") "reactive-list (existing)"))
(tr (td (~tw :tokens "p-2 font-mono") "L6") (td (~tw :tokens "p-2") "App shell — client-first rendering") (td (~tw :tokens "p-2") "sx-mount, all above")))))
;; =====================================================================
;; Implementation Plan
;; =====================================================================
(~docs/section :title "Implementation Plan" :id "implementation"
(p "All seven layers live in a single spec module: " (code "web/reactive-runtime.sx") ". "
"Every layer is a plain " (code "define") " function — no macros, no new special forms. "
"The OCaml evaluator bootstraps them to JavaScript via the standard transpiler pipeline.")
(~docs/subsection :title "Implementation Order"
(~docs/code :src (highlight
"L0 Ref → standalone, trivial (~35 LOC)\nL1 Foreign FFI → standalone, function factories (~100 LOC)\nL5 Keyed Lists → enhances existing reactive-list (~155 LOC)\nL2 State Machine → uses signals + dicts (~200 LOC)\nL4 Render Loop → uses L0 refs + existing rAF (~140 LOC)\nL3 Commands → extends stores, uses signals (~320 LOC)\nL6 App Shell → orchestrates all above (~330 LOC)\n Total: ~1280 LOC"
"text"))
(p "L0 and L1 are independent foundations. L5 enhances existing code. "
"L2 and L4 depend on L0. L3 builds on signals. L6 ties everything together."))
(~docs/subsection :title "Build Integration"
(p "One new entry in " (code "SPEC_MODULES") " (hosts/javascript/platform.py):")
(~docs/code :src (highlight
"SPEC_MODULES = {\n ...\n \"reactive-runtime\": (\"reactive-runtime.sx\", \"reactive-runtime (application patterns)\"),\n}\nSPEC_MODULE_ORDER = [..., \"reactive-runtime\"]"
"python"))
(p "Auto-included when the " (code "dom") " adapter is present. "
"Depends on " (code "signals") " (loaded first via module ordering)."))
(~docs/subsection :title "Existing Primitives Used"
(table (~tw :tokens "w-full mb-6 text-sm")
(thead
(tr (th (~tw :tokens "text-left p-2") "Primitive") (th (~tw :tokens "text-left p-2") "Used By")))
(tbody
(tr (td (~tw :tokens "p-2") (code "signal, deref, reset!, swap!, computed, effect")) (td (~tw :tokens "p-2") "L2, L3, L4"))
(tr (td (~tw :tokens "p-2") (code "host-call, host-get, host-set!")) (td (~tw :tokens "p-2") "L1 (Foreign FFI)"))
(tr (td (~tw :tokens "p-2") (code "request-animation-frame")) (td (~tw :tokens "p-2") "L4 (Render Loop)"))
(tr (td (~tw :tokens "p-2") (code "register-in-scope, scope-push!, scope-pop!")) (td (~tw :tokens "p-2") "L0, L4"))
(tr (td (~tw :tokens "p-2") (code "sx-mount, sx-hydrate-islands")) (td (~tw :tokens "p-2") "L6 (App Shell)"))
(tr (td (~tw :tokens "p-2") (code "DOM ops (dom-insert-after, dom-remove, …)")) (td (~tw :tokens "p-2") "L5 (Keyed Lists)"))))
(p (~tw :tokens "text-stone-600 italic")
"Zero new platform primitives validates that the existing interface is complete "
"for any application pattern.")))))

View File

@@ -0,0 +1,36 @@
;; ---------------------------------------------------------------------------
;; L5: Keyed Lists
;; ---------------------------------------------------------------------------
(defcomp ()
(~docs/page :title "Layer 5: Keyed List Reconciliation"
(p (~tw :tokens "text-stone-500 text-sm italic mb-8")
"Enhances the existing reactive-list with explicit key extraction callbacks "
"for stable identity tracking across updates.")
(~docs/section :title "Live Demo" :id "demo"
(p "Items with stable " (code ":key") " identities. Reverse and rotate reorder the signal list — the reconciler moves existing DOM nodes instead of recreating them. Add and remove demonstrate insertion and deletion.")
(~reactive-runtime/demo-keyed-lists)
(~docs/code :src (highlight "(defisland ~demo-keyed-lists ()\n (let ((items (signal (list\n (dict \"id\" 1 \"text\" \"Alpha\" \"color\" \"violet\")\n (dict \"id\" 2 \"text\" \"Beta\" \"color\" \"blue\") ...)))\n (next-id (signal 6)))\n (button :on-click (fn (e) (swap! items reverse)) \"Reverse\")\n (button :on-click (fn (e)\n (swap! items (fn (old) (append (rest old) (first old)))))\n \"Rotate\")\n (ul (map (fn (item)\n (li :key (str (get item \"id\"))\n :class (str \"bg-\" (get item \"color\") \"-100 ...\")\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items)))))" "lisp"))
(p (code ":key") " on each " (code "li") " gives the reconciler stable identity. When " (code "reverse") " flips the list, the existing DOM nodes are moved — not destroyed and recreated. This preserves focus, scroll position, CSS transitions, and internal island state."))
(~docs/section :title "API" :id "keyed-api"
(~docs/code :src (highlight
";; Current: keys extracted from rendered DOM key attribute\n(map (fn (el) (~shape-handle :key (get el :id) el)) (deref items))\n\n;; Enhanced: explicit key function\n(map (fn (el) (~shape-handle el)) (deref items)\n :key (fn (el) (get el :id)))"
"lisp"))
(p "The " (code ":key") " parameter provides a function that extracts a stable identity "
"from each item " (em "before") " rendering. This is more efficient than extracting keys "
"from rendered DOM nodes and handles cases where the rendered output doesn't have a "
"natural key attribute."))
(~docs/section :title "Changes" :id "keyed-changes"
(p "Enhancement to existing " (code "adapter-dom.sx") ":")
(ul (~tw :tokens "list-disc pl-6 mb-4 space-y-1")
(li (code "render-dom-list") " detection — parse " (code ":key") " kwarg after " (code "(deref sig)"))
(li (code "reactive-list") " — accept optional " (code "key-fn") ", use instead of " (code "extract-key") " when provided")
(li "Fully backward compatible — without " (code ":key") ", behavior unchanged"))
(p "~155 lines of changes. No new platform primitives — existing DOM ops suffice."))))

Some files were not shown because too many files have changed in this diff Show More