OCaml evaluator for page dispatch + handler aser, 83/83 Playwright tests

Major architectural change: page function dispatch and handler execution
now go through the OCaml kernel instead of the Python bootstrapped evaluator.

OCaml integration:
- Page dispatch: bridge.eval() evaluates SX URL expressions (geography, marshes, etc.)
- Handler aser: bridge.aser() serializes handler responses as SX wire format
- _ensure_components loads all .sx files into OCaml kernel (spec, web adapter, handlers)
- defhandler/defpage registered as no-op special forms so handler files load
- helper IO primitive dispatches to Python page helpers + IO handlers
- ok-raw response format for SX wire format (no double-escaping)
- Natural list serialization in eval (no (list ...) wrapper)
- Clean pipe: _read_until_ok always sends io-response on error

SX adapter (aser):
- scope-emit!/scope-peek aliases to avoid CEK special form conflict
- aser-fragment/aser-call: strings starting with "(" pass through unserialized
- Registered cond-scheme?, is-else-clause?, primitive?, get-primitive in kernel
- random-int, parse-int as kernel primitives; json-encode, into via IO bridge

Handler migration:
- All IO calls converted to (helper "name" args...) pattern
- request-arg, request-form, state-get, state-set!, now, component-source etc.
- Fixed bare (effect ...) in island bodies leaking disposer functions as text
- Fixed lower-case → lower, ~search-results → ~examples/search-results

Reactive islands:
- sx-hydrate-islands called after client-side navigation swap
- force-dispose-islands-in for outerHTML swaps (clears hydration markers)
- clear-processed! platform primitive for re-hydration

Content restructuring:
- Design, event bridge, named stores, phase 2 consolidated into reactive overview
- Marshes split into overview + 5 example sub-pages
- Nav links use sx-get/sx-target for client-side navigation

Playwright test suite (sx/tests/test_demos.py):
- 83 tests covering hypermedia demos, reactive islands, marshes, spec explorer
- Server-side rendering, handler interactions, island hydration, navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 17:22:51 +00:00
parent 5b6e883e6d
commit 71c2003a60
33 changed files with 1848 additions and 852 deletions

View File

@@ -1,90 +1,10 @@
"""Documentation content for the sx docs site.
All page content as Python data structures, consumed by sx_components.py
to build s-expression page trees.
Data structures consumed by helpers.py for pages that need server-side data.
Navigation is defined in nav-data.sx (the single source of truth).
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Navigation
# ---------------------------------------------------------------------------
DOCS_NAV = [
("Introduction", "/(language.(doc.introduction))"),
("Getting Started", "/(language.(doc.getting-started))"),
("Components", "/(language.(doc.components))"),
("Evaluator", "/(language.(doc.evaluator))"),
("Primitives", "/(language.(doc.primitives))"),
("CSS", "/(language.(doc.css))"),
("Server Rendering", "/(language.(doc.server-rendering))"),
]
REFERENCE_NAV = [
("Attributes", "/(geography.(hypermedia.(reference.attributes)))"),
("Headers", "/(geography.(hypermedia.(reference.headers)))"),
("Events", "/(geography.(hypermedia.(reference.events)))"),
("JS API", "/(geography.(hypermedia.(reference.js-api)))"),
]
PROTOCOLS_NAV = [
("Wire Format", "/(applications.(protocol.wire-format))"),
("Fragments", "/(applications.(protocol.fragments))"),
("Resolver I/O", "/(applications.(protocol.resolver-io))"),
("Internal Services", "/(applications.(protocol.internal-services))"),
("ActivityPub", "/(applications.(protocol.activitypub))"),
("Future", "/(applications.(protocol.future))"),
]
EXAMPLES_NAV = [
("Click to Load", "/(geography.(hypermedia.(example.click-to-load)))"),
("Form Submission", "/(geography.(hypermedia.(example.form-submission)))"),
("Polling", "/(geography.(hypermedia.(example.polling)))"),
("Delete Row", "/(geography.(hypermedia.(example.delete-row)))"),
("Inline Edit", "/(geography.(hypermedia.(example.inline-edit)))"),
("OOB Swaps", "/(geography.(hypermedia.(example.oob-swaps)))"),
("Lazy Loading", "/(geography.(hypermedia.(example.lazy-loading)))"),
("Infinite Scroll", "/(geography.(hypermedia.(example.infinite-scroll)))"),
("Progress Bar", "/(geography.(hypermedia.(example.progress-bar)))"),
("Active Search", "/(geography.(hypermedia.(example.active-search)))"),
("Inline Validation", "/(geography.(hypermedia.(example.inline-validation)))"),
("Value Select", "/(geography.(hypermedia.(example.value-select)))"),
("Reset on Submit", "/(geography.(hypermedia.(example.reset-on-submit)))"),
("Edit Row", "/(geography.(hypermedia.(example.edit-row)))"),
("Bulk Update", "/(geography.(hypermedia.(example.bulk-update)))"),
("Swap Positions", "/(geography.(hypermedia.(example.swap-positions)))"),
("Select Filter", "/(geography.(hypermedia.(example.select-filter)))"),
("Tabs", "/(geography.(hypermedia.(example.tabs)))"),
("Animations", "/(geography.(hypermedia.(example.animations)))"),
("Dialogs", "/(geography.(hypermedia.(example.dialogs)))"),
("Keyboard Shortcuts", "/(geography.(hypermedia.(example.keyboard-shortcuts)))"),
("PUT / PATCH", "/(geography.(hypermedia.(example.put-patch)))"),
("JSON Encoding", "/(geography.(hypermedia.(example.json-encoding)))"),
("Vals & Headers", "/(geography.(hypermedia.(example.vals-and-headers)))"),
("Loading States", "/(geography.(hypermedia.(example.loading-states)))"),
("Request Abort", "/(geography.(hypermedia.(example.sync-replace)))"),
("Retry", "/(geography.(hypermedia.(example.retry)))"),
]
ESSAYS_NAV = [
("sx sucks", "/(etc.(essay.sx-sucks))"),
("Why S-Expressions", "/(etc.(essay.why-sexps))"),
("The htmx/React Hybrid", "/(etc.(essay.htmx-react-hybrid))"),
("On-Demand CSS", "/(etc.(essay.on-demand-css))"),
("Client Reactivity", "/(etc.(essay.client-reactivity))"),
("SX Native", "/(etc.(essay.sx-native))"),
("The SX Manifesto", "/(etc.(philosophy.sx-manifesto))"),
("Tail-Call Optimization", "/(etc.(essay.tail-call-optimization))"),
("Continuations", "/(etc.(essay.continuations))"),
]
MAIN_NAV = [
("Docs", "/(language.(doc.introduction))"),
("Reference", "/(geography.(hypermedia.(reference)))"),
("Protocols", "/(applications.(protocol.wire-format))"),
("Examples", "/(geography.(hypermedia.(example.click-to-load)))"),
("Essays", "/(etc.(essay.sx-sucks))"),
]
# ---------------------------------------------------------------------------
# Reference: Attributes
# ---------------------------------------------------------------------------

View File

@@ -58,18 +58,18 @@
(let ((running (signal false))
(elapsed (signal 0))
(time-text (create-text-node "0.0s"))
(btn-text (create-text-node "Start")))
(effect (fn ()
(when (deref running)
(let ((id (set-interval (fn () (swap! elapsed inc)) 100)))
(fn () (clear-interval id))))))
(effect (fn ()
(let ((e (deref elapsed)))
(dom-set-text-content time-text
(str (floor (/ e 10)) "." (mod e 10) "s")))))
(effect (fn ()
(dom-set-text-content btn-text
(if (deref running) "Stop" "Start"))))
(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 :class "rounded-lg border border-stone-200 p-4"
(div :class "flex items-center gap-3"
(span :class "text-2xl font-bold text-violet-700 font-mono min-w-[5ch]" time-text)
@@ -82,10 +82,10 @@
(defisland ~geography/cek/demo-batch ()
(let ((first-sig (signal 0))
(second-sig (signal 0))
(renders (signal 0)))
(effect (fn ()
(deref first-sig) (deref second-sig)
(swap! renders inc)))
(renders (signal 0))
(_eff (effect (fn ()
(deref first-sig) (deref second-sig)
(swap! renders inc)))))
(div :class "rounded-lg border border-stone-200 p-4 space-y-2"
(div :class "flex items-center gap-4 text-sm"
(span (str "first: " (deref first-sig)))

View File

@@ -59,11 +59,11 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%Y-%m-%d %H:%M:%S")))
(let ((now (helper "now" "%Y-%m-%d %H:%M:%S")))
(<>
(~examples/click-result :time now)
(~docs/oob-code :target-id "click-comp"
:text (component-source "~examples/click-result"))
:text (helper "component-source" "~examples/click-result"))
(~docs/oob-code :target-id "click-wire"
:text (str "(~examples/click-result :time \"" now "\")")))))
@@ -78,11 +78,11 @@
:csrf false
:returns "element"
(&key)
(let ((name (request-form "name" "")))
(let ((name (helper "request-form" "name" "")))
(<>
(~examples/form-result :name name)
(~docs/oob-code :target-id "form-comp"
:text (component-source "~examples/form-result"))
:text (helper "component-source" "~examples/form-result"))
(~docs/oob-code :target-id "form-wire"
:text (str "(~examples/form-result :name \"" name "\")")))))
@@ -96,16 +96,17 @@
:method :get
:returns "element"
(&key)
(let ((n (+ (state-get "ex-poll-n" 0) 1)))
(state-set! "ex-poll-n" n)
(let ((now (now "%H:%M:%S"))
(let ((prev (helper "state-get" "ex-poll-n" 0)))
(let ((n (+ prev 1)))
(helper "state-set!" "ex-poll-n" n)
(let ((now (helper "now" "%H:%M:%S"))
(count (if (< n 10) n 10)))
(<>
(~examples/poll-result :time now :count count)
(~docs/oob-code :target-id "poll-comp"
:text (component-source "~examples/poll-result"))
:text (helper "component-source" "~examples/poll-result"))
(~docs/oob-code :target-id "poll-wire"
:text (str "(~examples/poll-result :time \"" now "\" :count " count ")"))))))
:text (str "(~examples/poll-result :time \"" now "\" :count " count ")")))))))
;; --------------------------------------------------------------------------
@@ -120,7 +121,7 @@
(&key item-id)
(<>
(~docs/oob-code :target-id "delete-comp"
:text (component-source "~examples/delete-row"))
:text (helper "component-source" "~examples/delete-row"))
(~docs/oob-code :target-id "delete-wire"
:text "(empty — row removed by outerHTML swap)")))
@@ -134,11 +135,11 @@
:method :get
:returns "element"
(&key)
(let ((value (request-arg "value" "")))
(let ((value (helper "request-arg" "value" "")))
(<>
(~examples/inline-edit-form :value value)
(~docs/oob-code :target-id "edit-comp"
:text (component-source "~examples/inline-edit-form"))
:text (helper "component-source" "~examples/inline-edit-form"))
(~docs/oob-code :target-id "edit-wire"
:text (str "(~examples/inline-edit-form :value \"" value "\")")))))
@@ -148,11 +149,11 @@
:csrf false
:returns "element"
(&key)
(let ((value (request-form "value" "")))
(let ((value (helper "request-form" "value" "")))
(<>
(~examples/inline-view :value value)
(~docs/oob-code :target-id "edit-comp"
:text (component-source "~examples/inline-view"))
:text (helper "component-source" "~examples/inline-view"))
(~docs/oob-code :target-id "edit-wire"
:text (str "(~examples/inline-view :value \"" value "\")")))))
@@ -161,11 +162,11 @@
:method :get
:returns "element"
(&key)
(let ((value (request-arg "value" "")))
(let ((value (helper "request-arg" "value" "")))
(<>
(~examples/inline-view :value value)
(~docs/oob-code :target-id "edit-comp"
:text (component-source "~examples/inline-view"))
:text (helper "component-source" "~examples/inline-view"))
(~docs/oob-code :target-id "edit-wire"
:text (str "(~examples/inline-view :value \"" value "\")")))))
@@ -179,7 +180,7 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(p :class "text-emerald-600 font-medium" "Box A updated!")
(p :class "text-sm text-stone-500" (str "at " now))
@@ -199,11 +200,11 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(~examples/lazy-result :time now)
(~docs/oob-code :target-id "lazy-comp"
:text (component-source "~examples/lazy-result"))
:text (helper "component-source" "~examples/lazy-result"))
(~docs/oob-code :target-id "lazy-wire"
:text (str "(~examples/lazy-result :time \"" now "\")")))))
@@ -217,7 +218,7 @@
:method :get
:returns "element"
(&key)
(let ((page (request-arg "page" "2")))
(let ((page (helper "request-arg" "page" "2")))
(let ((pg (parse-int page))
(start (+ (* (- (parse-int page) 1) 5) 1)))
(<>
@@ -249,30 +250,31 @@
:csrf false
:returns "element"
(&key)
(let ((n (+ (state-get "ex-job-counter" 0) 1)))
(state-set! "ex-job-counter" n)
(let ((prev-job (helper "state-get" "ex-job-counter" 0)))
(let ((n (+ prev-job 1)))
(helper "state-set!" "ex-job-counter" n)
(let ((job-id (str "job-" n)))
(state-set! (str "ex-job-" job-id) 0)
(helper "state-set!" (str "ex-job-" job-id) 0)
(<>
(~examples/progress-status :percent 0 :job-id job-id)
(~docs/oob-code :target-id "progress-comp"
:text (component-source "~examples/progress-status"))
:text (helper "component-source" "~examples/progress-status"))
(~docs/oob-code :target-id "progress-wire"
:text (str "(~examples/progress-status :percent 0 :job-id \"" job-id "\")"))))))
:text (str "(~examples/progress-status :percent 0 :job-id \"" job-id "\")")))))))
(defhandler ex-progress-status
:path "/sx/(geography.(hypermedia.(example.(api.progress-status))))"
:method :get
:returns "element"
(&key)
(let ((job-id (request-arg "job" "")))
(let ((current (state-get (str "ex-job-" job-id) 0)))
(let ((job-id (helper "request-arg" "job" "")))
(let ((current (helper "state-get" (str "ex-job-" job-id) 0)))
(let ((next (if (>= (+ current (random-int 15 30)) 100) 100 (+ current (random-int 15 30)))))
(state-set! (str "ex-job-" job-id) next)
(helper "state-set!" (str "ex-job-" job-id) next)
(<>
(~examples/progress-status :percent next :job-id job-id)
(~docs/oob-code :target-id "progress-comp"
:text (component-source "~examples/progress-status"))
:text (helper "component-source" "~examples/progress-status"))
(~docs/oob-code :target-id "progress-wire"
:text (str "(~examples/progress-status :percent " next " :job-id \"" job-id "\")")))))))
@@ -286,17 +288,17 @@
:method :get
:returns "element"
(&key)
(let ((q (request-arg "q" "")))
(let ((q (helper "request-arg" "q" "")))
(let ((results (if (= q "")
search-languages
(filter (fn (lang) (contains? (lower-case lang) (lower-case q)))
(filter (fn (lang) (contains? (lower lang) (lower q)))
search-languages))))
(<>
(~search-results :items results :query q)
(~examples/search-results :items results :query q)
(~docs/oob-code :target-id "search-comp"
:text (component-source "~search-results"))
:text (helper "component-source" "~examples/search-results"))
(~docs/oob-code :target-id "search-wire"
:text (str "(~search-results :items (list ...) :query \"" q "\")"))))))
:text (str "(~examples/search-results :items (list ...) :query \"" q "\")"))))))
;; --------------------------------------------------------------------------
@@ -308,27 +310,37 @@
:method :get
:returns "element"
(&key)
(let ((email (request-arg "email" "")))
(let ((result
(cond
(= email "")
(list "validation-error" "(~examples/validation-error :message \"Email is required\")"
(~examples/validation-error :message "Email is required"))
(not (contains? email "@"))
(list "validation-error" "(~examples/validation-error :message \"Invalid email format\")"
(~examples/validation-error :message "Invalid email format"))
(some (fn (e) (= (lower-case e) (lower-case email))) taken-emails)
(list "validation-error" (str "(~examples/validation-error :message \"" email " is already taken\")")
(~examples/validation-error :message (str email " is already taken")))
:else
(list "validation-ok" (str "(~examples/validation-ok :email \"" email "\")")
(~examples/validation-ok :email email)))))
(<>
(nth result 2)
(~docs/oob-code :target-id "validate-comp"
:text (component-source (first result)))
(~docs/oob-code :target-id "validate-wire"
:text (nth result 1))))))
(let ((email (helper "request-arg" "email" "")))
(cond
(= email "")
(<>
(~examples/validation-error :message "Email is required")
(~docs/oob-code :target-id "validate-comp"
:text (helper "component-source" "~examples/validation-error"))
(~docs/oob-code :target-id "validate-wire"
:text "(~examples/validation-error :message \"Email is required\")"))
(not (contains? email "@"))
(<>
(~examples/validation-error :message "Invalid email format")
(~docs/oob-code :target-id "validate-comp"
:text (helper "component-source" "~examples/validation-error"))
(~docs/oob-code :target-id "validate-wire"
:text "(~examples/validation-error :message \"Invalid email format\")"))
(some (fn (e) (= (lower e) (lower email))) taken-emails)
(<>
(~examples/validation-error :message (str email " is already taken"))
(~docs/oob-code :target-id "validate-comp"
:text (helper "component-source" "~examples/validation-error"))
(~docs/oob-code :target-id "validate-wire"
:text (str "(~examples/validation-error :message \"" email " is already taken\")")))
:else
(<>
(~examples/validation-ok :email email)
(~docs/oob-code :target-id "validate-comp"
:text (helper "component-source" "~examples/validation-ok"))
(~docs/oob-code :target-id "validate-wire"
:text (str "(~examples/validation-ok :email \"" email "\")"))))))
(defhandler ex-validate-submit
:path "/sx/(geography.(hypermedia.(example.(api.validate-submit))))"
@@ -336,7 +348,7 @@
:csrf false
:returns "element"
(&key)
(let ((email (request-form "email" "")))
(let ((email (helper "request-form" "email" "")))
(if (or (= email "") (not (contains? email "@")))
(p :class "text-sm text-rose-600 mt-2" "Please enter a valid email.")
(p :class "text-sm text-emerald-600 mt-2" (str "Form submitted with: " email)))))
@@ -351,15 +363,14 @@
:method :get
:returns "element"
(&key)
(let ((cat (request-arg "category" "")))
(let ((cat (helper "request-arg" "category" "")))
(let ((items (get value-select-data cat (list))))
(let ((options (if (empty? items)
(list (option :value "" "No items"))
(map (fn (i) (option :value i i)) items))))
(<>
options
(~docs/oob-code :target-id "values-wire"
:text (str "(options for \"" cat "\")")))))))
(<>
(if (empty? items)
(option :value "" "No items")
(map (fn (i) (option :value i i)) items))
(~docs/oob-code :target-id "values-wire"
:text (str "(options for \"" cat "\")"))))))
;; --------------------------------------------------------------------------
@@ -372,12 +383,12 @@
:csrf false
:returns "element"
(&key)
(let ((msg (request-form "message" "(empty)"))
(now (now "%H:%M:%S")))
(let ((msg (helper "request-form" "message" "(empty)"))
(now (helper "now" "%H:%M:%S")))
(<>
(~examples/reset-message :message msg :time now)
(~docs/oob-code :target-id "reset-comp"
:text (component-source "~examples/reset-message"))
:text (helper "component-source" "~examples/reset-message"))
(~docs/oob-code :target-id "reset-wire"
:text (str "(~examples/reset-message :message \"" msg "\" :time \"" now "\")")))))
@@ -392,12 +403,12 @@
:returns "element"
(&key row-id)
(let ((default (get edit-row-defaults row-id {"id" row-id "name" "" "price" "0" "stock" "0"})))
(let ((row (state-get (str "ex-row-" row-id) default)))
(let ((row (helper "state-get" (str "ex-row-" row-id) default)))
(<>
(~examples/edit-row-form :id (get row "id") :name (get row "name")
:price (get row "price") :stock (get row "stock"))
(~docs/oob-code :target-id "editrow-comp"
:text (component-source "~examples/edit-row-form"))
:text (helper "component-source" "~examples/edit-row-form"))
(~docs/oob-code :target-id "editrow-wire"
:text (str "(~examples/edit-row-form :id \"" (get row "id") "\" ...)"))))))
@@ -407,15 +418,15 @@
:csrf false
:returns "element"
(&key row-id)
(let ((name (request-form "name" ""))
(price (request-form "price" "0"))
(stock (request-form "stock" "0")))
(state-set! (str "ex-row-" row-id)
(let ((name (helper "request-form" "name" ""))
(price (helper "request-form" "price" "0"))
(stock (helper "request-form" "stock" "0")))
(helper "state-set!" (str "ex-row-" row-id)
{"id" row-id "name" name "price" price "stock" stock})
(<>
(~examples/edit-row-view :id row-id :name name :price price :stock stock)
(~docs/oob-code :target-id "editrow-comp"
:text (component-source "~examples/edit-row-view"))
:text (helper "component-source" "~examples/edit-row-view"))
(~docs/oob-code :target-id "editrow-wire"
:text (str "(~examples/edit-row-view :id \"" row-id "\" ...)")))))
@@ -425,12 +436,12 @@
:returns "element"
(&key row-id)
(let ((default (get edit-row-defaults row-id {"id" row-id "name" "" "price" "0" "stock" "0"})))
(let ((row (state-get (str "ex-row-" row-id) default)))
(let ((row (helper "state-get" (str "ex-row-" row-id) default)))
(<>
(~examples/edit-row-view :id (get row "id") :name (get row "name")
:price (get row "price") :stock (get row "stock"))
(~docs/oob-code :target-id "editrow-comp"
:text (component-source "~examples/edit-row-view"))
:text (helper "component-source" "~examples/edit-row-view"))
(~docs/oob-code :target-id "editrow-wire"
:text (str "(~examples/edit-row-view :id \"" (get row "id") "\" ...)"))))))
@@ -444,29 +455,29 @@
:csrf false
:returns "element"
(&key)
(let ((action (request-arg "action" "activate"))
(ids (request-form-list "ids")))
(let ((action (helper "request-arg" "action" "activate"))
(ids (helper "request-form-list" "ids")))
(let ((new-status (if (= action "activate") "active" "inactive")))
;; Update matching users in state
(for-each (fn (uid)
(let ((default (get bulk-user-defaults uid nil)))
(let ((user (state-get (str "ex-bulk-" uid) default)))
(let ((user (helper "state-get" (str "ex-bulk-" uid) default)))
(when user
(state-set! (str "ex-bulk-" uid)
(helper "state-set!" (str "ex-bulk-" uid)
(assoc user "status" new-status))))))
ids)
;; Return all rows
(let ((rows (map (fn (uid)
(let ((default (get bulk-user-defaults uid
{"id" uid "name" "" "email" "" "status" "active"})))
(let ((u (state-get (str "ex-bulk-" uid) default)))
(let ((u (helper "state-get" (str "ex-bulk-" uid) default)))
(~examples/bulk-row :id (get u "id") :name (get u "name")
:email (get u "email") :status (get u "status")))))
(list "1" "2" "3" "4" "5"))))
(<>
rows
(~docs/oob-code :target-id "bulk-comp"
:text (component-source "~examples/bulk-row"))
:text (helper "component-source" "~examples/bulk-row"))
(~docs/oob-code :target-id "bulk-wire"
:text (str "(updated " (len ids) " users to " new-status ")")))))))
@@ -481,10 +492,11 @@
:csrf false
:returns "element"
(&key)
(let ((mode (request-arg "mode" "beforeend"))
(n (+ (state-get "ex-swap-n" 0) 1))
(now (now "%H:%M:%S")))
(state-set! "ex-swap-n" n)
(let ((mode (helper "request-arg" "mode" "beforeend"))
(prev-swap (helper "state-get" "ex-swap-n" 0))
(now (helper "now" "%H:%M:%S")))
(let ((n (+ prev-swap 1)))
(helper "state-set!" "ex-swap-n" n)
(<>
(div :class "px-3 py-2 text-sm text-stone-700"
(str "[" now "] " mode " (#" n ")"))
@@ -492,7 +504,7 @@
:class "self-center text-sm text-stone-500"
(str "Count: " n))
(~docs/oob-code :target-id "swap-wire"
:text (str "(entry + oob counter: " n ")")))))
:text (str "(entry + oob counter: " n ")"))))))
;; --------------------------------------------------------------------------
@@ -504,7 +516,7 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(div :id "dash-header" :class "p-3 bg-violet-50 rounded mb-3"
(h4 :class "font-semibold text-violet-800" "Dashboard Header")
@@ -556,12 +568,12 @@
:returns "element"
(&key)
(let ((idx (random-int 0 4))
(now (now "%H:%M:%S")))
(now (helper "now" "%H:%M:%S")))
(let ((color (nth anim-colors idx)))
(<>
(~anim-result :color color :time now)
(~docs/oob-code :target-id "anim-comp"
:text (component-source "~anim-result"))
:text (helper "component-source" "~anim-result"))
(~docs/oob-code :target-id "anim-wire"
:text (str "(~anim-result :color \"" color "\" :time \"" now "\")"))))))
@@ -579,7 +591,7 @@
(~examples/dialog-modal :title "Confirm Action"
:message "Are you sure you want to proceed? This is a demo dialog rendered entirely with sx components.")
(~docs/oob-code :target-id "dialog-comp"
:text (component-source "~examples/dialog-modal"))
:text (helper "component-source" "~examples/dialog-modal"))
(~docs/oob-code :target-id "dialog-wire"
:text "(~examples/dialog-modal :title \"Confirm Action\" :message \"...\")")))
@@ -602,12 +614,12 @@
:method :get
:returns "element"
(&key)
(let ((key (request-arg "key" "")))
(let ((key (helper "request-arg" "key" "")))
(let ((action (get kbd-actions key (str "Unknown key: " key))))
(<>
(~examples/kbd-result :key key :action action)
(~docs/oob-code :target-id "kbd-comp"
:text (component-source "~examples/kbd-result"))
:text (helper "component-source" "~examples/kbd-result"))
(~docs/oob-code :target-id "kbd-wire"
:text (str "(~examples/kbd-result :key \"" key "\" :action \"" action "\")"))))))
@@ -621,12 +633,12 @@
:method :get
:returns "element"
(&key)
(let ((p (state-get "ex-profile"
(let ((p (helper "state-get" "ex-profile"
{"name" "Ada Lovelace" "email" "ada@example.com" "role" "Engineer"})))
(<>
(~examples/pp-form-full :name (get p "name") :email (get p "email") :role (get p "role"))
(~docs/oob-code :target-id "pp-comp"
:text (component-source "~examples/pp-form-full"))
:text (helper "component-source" "~examples/pp-form-full"))
(~docs/oob-code :target-id "pp-wire"
:text (str "(~examples/pp-form-full :name \"" (get p "name") "\" ...)")))))
@@ -636,14 +648,14 @@
:csrf false
:returns "element"
(&key)
(let ((name (request-form "name" ""))
(email (request-form "email" ""))
(role (request-form "role" "")))
(state-set! "ex-profile" {"name" name "email" email "role" role})
(let ((name (helper "request-form" "name" ""))
(email (helper "request-form" "email" ""))
(role (helper "request-form" "role" "")))
(helper "state-set!" "ex-profile" {"name" name "email" email "role" role})
(<>
(~examples/pp-view :name name :email email :role role)
(~docs/oob-code :target-id "pp-comp"
:text (component-source "~examples/pp-view"))
:text (helper "component-source" "~examples/pp-view"))
(~docs/oob-code :target-id "pp-wire"
:text (str "(~examples/pp-view :name \"" name "\" ...)")))))
@@ -652,12 +664,12 @@
:method :get
:returns "element"
(&key)
(let ((p (state-get "ex-profile"
(let ((p (helper "state-get" "ex-profile"
{"name" "Ada Lovelace" "email" "ada@example.com" "role" "Engineer"})))
(<>
(~examples/pp-view :name (get p "name") :email (get p "email") :role (get p "role"))
(~docs/oob-code :target-id "pp-comp"
:text (component-source "~examples/pp-view"))
:text (helper "component-source" "~examples/pp-view"))
(~docs/oob-code :target-id "pp-wire"
:text (str "(~examples/pp-view :name \"" (get p "name") "\" ...)")))))
@@ -672,13 +684,13 @@
:csrf false
:returns "element"
(&key)
(let ((data (request-json))
(ct (request-content-type)))
(let ((data (helper "request-json"))
(ct (helper "request-content-type")))
(let ((body (json-encode data)))
(<>
(~examples/json-result :body body :content-type ct)
(~docs/oob-code :target-id "json-comp"
:text (component-source "~examples/json-result"))
:text (helper "component-source" "~examples/json-result"))
(~docs/oob-code :target-id "json-wire"
:text (str "(~examples/json-result :body \"" body "\" :content-type \"" ct "\")"))))))
@@ -692,7 +704,7 @@
:method :get
:returns "element"
(&key)
(let ((vals (into (list) (request-args-all))))
(let ((vals (helper "into" (list) (helper "request-args-all"))))
(let ((filtered (filter (fn (pair) (and (not (= (first pair) "_"))
(not (= (first pair) "sx-request"))))
vals)))
@@ -700,7 +712,7 @@
(<>
(~examples/echo-result :label "values" :items items)
(~docs/oob-code :target-id "vals-comp"
:text (component-source "~examples/echo-result"))
:text (helper "component-source" "~examples/echo-result"))
(~docs/oob-code :target-id "vals-wire"
:text (str "(~examples/echo-result :label \"values\" :items (list ...))")))))))
@@ -709,13 +721,13 @@
:method :get
:returns "element"
(&key)
(let ((all-headers (into (list) (request-headers-all))))
(let ((all-headers (helper "into" (list) (helper "request-headers-all"))))
(let ((custom (filter (fn (pair) (starts-with? (first pair) "x-")) all-headers)))
(let ((items (map (fn (pair) (str (first pair) ": " (nth pair 1))) custom)))
(<>
(~examples/echo-result :label "headers" :items items)
(~docs/oob-code :target-id "vals-comp"
:text (component-source "~examples/echo-result"))
:text (helper "component-source" "~examples/echo-result"))
(~docs/oob-code :target-id "vals-wire"
:text (str "(~examples/echo-result :label \"headers\" :items (list ...))")))))))
@@ -730,11 +742,11 @@
:returns "element"
(&key)
(sleep 2000)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(~examples/loading-result :time now)
(~docs/oob-code :target-id "loading-comp"
:text (component-source "~examples/loading-result"))
:text (helper "component-source" "~examples/loading-result"))
(~docs/oob-code :target-id "loading-wire"
:text (str "(~examples/loading-result :time \"" now "\")")))))
@@ -750,11 +762,11 @@
(&key)
(let ((delay-ms (random-int 500 2000)))
(sleep delay-ms)
(let ((q (request-arg "q" "")))
(let ((q (helper "request-arg" "q" "")))
(<>
(~examples/sync-result :query q :delay (str delay-ms))
(~docs/oob-code :target-id "sync-comp"
:text (component-source "~examples/sync-result"))
:text (helper "component-source" "~examples/sync-result"))
(~docs/oob-code :target-id "sync-wire"
:text (str "(~examples/sync-result :query \"" q "\" :delay \"" delay-ms "\")"))))))
@@ -768,8 +780,9 @@
:method :get
:returns "element"
(&key)
(let ((n (+ (state-get "ex-flaky-n" 0) 1)))
(state-set! "ex-flaky-n" n)
(let ((prev-flaky (helper "state-get" "ex-flaky-n" 0)))
(let ((n (+ prev-flaky 1)))
(helper "state-set!" "ex-flaky-n" n)
(if (not (= (mod n 3) 0))
(do
(set-response-status 503)
@@ -777,6 +790,6 @@
(<>
(~examples/retry-result :attempt (str n) :message "Success! The endpoint finally responded.")
(~docs/oob-code :target-id "retry-comp"
:text (component-source "~examples/retry-result"))
:text (helper "component-source" "~examples/retry-result"))
(~docs/oob-code :target-id "retry-wire"
:text (str "(~examples/retry-result :attempt \"" n "\" ...)"))))))
:text (str "(~examples/retry-result :attempt \"" n "\" ...)")))))))

View File

@@ -10,7 +10,7 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(span :class "text-stone-800 text-sm" "Server time: " (strong now))
(~docs/oob-code :target-id "ref-wire-sx-get"
@@ -24,7 +24,7 @@
:csrf false
:returns "element"
(&key)
(let ((name (request-form "name" "stranger")))
(let ((name (helper "request-form" "name" "stranger")))
(<>
(span :class "text-stone-800 text-sm" "Hello, " (strong name) "!")
(~docs/oob-code :target-id "ref-wire-sx-post"
@@ -38,7 +38,7 @@
:csrf false
:returns "element"
(&key)
(let ((status (request-form "status" "unknown")))
(let ((status (helper "request-form" "status" "unknown")))
(<>
(span :class "text-stone-700 text-sm" "Status: " (strong status) " — updated via PUT")
(~docs/oob-code :target-id "ref-wire-sx-put"
@@ -52,7 +52,7 @@
:csrf false
:returns "element"
(&key)
(let ((theme (request-form "theme" "unknown")))
(let ((theme (helper "request-form" "theme" "unknown")))
(<>
theme
(~docs/oob-code :target-id "ref-wire-sx-patch"
@@ -76,7 +76,7 @@
:method :get
:returns "element"
(&key)
(let ((q (request-arg "q" "")))
(let ((q (helper "request-arg" "q" "")))
(let ((sx-text (if (= q "")
"(span :class \"text-stone-400 text-sm\" \"Start typing to trigger a search.\")"
(str "(span :class \"text-stone-800 text-sm\" \"Results for: \" (strong \"" q "\"))"))))
@@ -93,7 +93,7 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(div :class "text-sm text-violet-700" (str "New item (" now ")"))
(~docs/oob-code :target-id "ref-wire-sx-swap"
@@ -106,7 +106,7 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(span :class "text-emerald-700 text-sm" "Main updated at " now)
(div :id "ref-oob-side" :sx-swap-oob "innerHTML"
@@ -121,7 +121,7 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(div :id "the-header" (h3 "Page header — not selected"))
(div :id "the-content"
@@ -138,7 +138,7 @@
:method :get
:returns "element"
(&key)
(let ((q (request-arg "q" "")))
(let ((q (helper "request-arg" "q" "")))
(sleep 800)
(<>
(span :class "text-stone-800 text-sm" "Echo: " (strong q))
@@ -152,7 +152,7 @@
:method :get
:returns "element"
(&key)
(let ((name (request-header "SX-Prompt" "anonymous")))
(let ((name (helper "request-header" "SX-Prompt" "anonymous")))
(<>
(span :class "text-stone-800 text-sm" "Hello, " (strong name) "!")
(~docs/oob-code :target-id "ref-wire-sx-prompt"
@@ -180,7 +180,7 @@
:csrf false
:returns "element"
(&key)
(let ((name (request-file-name "file")))
(let ((name (helper "request-file-name" "file")))
(let ((display (if (nil? name) "(no file)" name)))
(let ((sx-text (str "(span :class \"text-stone-800 text-sm\" \"Received: \" (strong \"" display "\"))")))
(<>
@@ -194,7 +194,7 @@
:method :get
:returns "element"
(&key)
(let ((all-headers (into (list) (request-headers-all))))
(let ((all-headers (into (list) (helper "request-headers-all"))))
(let ((custom (filter
(fn (pair) (starts-with? (first pair) "x-"))
all-headers)))
@@ -218,7 +218,7 @@
:method :get
:returns "element"
(&key)
(let ((vals (into (list) (request-args-all))))
(let ((vals (into (list) (helper "request-args-all"))))
(let ((sx-text
(if (empty? vals)
"(span :class \"text-stone-400 text-sm\" \"No values received.\")"
@@ -240,7 +240,7 @@
:csrf false
:returns "element"
(&key)
(let ((vals (into (list) (request-form-all))))
(let ((vals (into (list) (helper "request-form-all"))))
(let ((sx-text
(if (empty? vals)
"(span :class \"text-stone-400 text-sm\" \"No values received.\")"
@@ -261,8 +261,9 @@
:method :get
:returns "element"
(&key)
(let ((n (+ (state-get "ref-flaky-n" 0) 1)))
(state-set! "ref-flaky-n" n)
(let ((prev (helper "state-get" "ref-flaky-n" 0)))
(let ((n (+ prev 1)))
(helper "state-set!" "ref-flaky-n" n)
(if (not (= (mod n 3) 0))
(do
(set-response-status 503)
@@ -270,7 +271,7 @@
(let ((sx-text (str "(span :class \"text-emerald-700 text-sm\" \"Success on attempt \" \"" n "\" \"!\")")))
(<>
(span :class "text-emerald-700 text-sm" "Success on attempt " (str n) "!")
(~docs/oob-code :target-id "ref-wire-sx-retry" :text sx-text))))))
(~docs/oob-code :target-id "ref-wire-sx-retry" :text sx-text)))))))
;; --- sx-trigger-event demo: response header triggers ---
@@ -279,7 +280,7 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(set-response-header "SX-Trigger" "showNotice")
(<>
(span :class "text-stone-800 text-sm" "Loaded at " (strong now) " — check the border!"))))
@@ -291,7 +292,7 @@
:method :get
:returns "element"
(&key)
(let ((now (now "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(set-response-header "SX-Retarget" "#ref-hdr-retarget-alt")
(<>
(span :class "text-violet-700 text-sm" "Retargeted at " (strong now)))))

View File

@@ -195,30 +195,30 @@
;; Validate — reset to default if out of range
(when (or (< (deref step-idx) 0) (> (deref step-idx) 16))
(reset! step-idx 9))))
;; Auto-parse via effect
(effect (fn ()
(let ((parsed (sx-parse source)))
(when (not (empty? parsed))
(let ((result (list))
(step-ref (dict "v" 0)))
(split-tag (first parsed) result)
(reset! steps result)
(let ((tokens (list)))
(dict-set! step-ref "v" 0)
(build-code-tokens (first parsed) tokens step-ref 0)
(reset! code-tokens tokens))
;; Defer code DOM build until lake exists
(schedule-idle (fn ()
(build-code-dom)
;; Clear preview and replay to initial step-idx
(let ((preview (get-preview)))
(when preview (dom-set-prop preview "innerHTML" "")))
(let ((target (deref step-idx)))
(reset! step-idx 0)
(set-stack (list (get-preview)))
(for-each (fn (_) (do-step)) (slice (deref steps) 0 target)))
(update-code-highlight)
(run-post-render-hooks))))))))
;; Auto-parse via effect (bind to _ to suppress return value in DOM)
(let ((_eff (effect (fn ()
(let ((parsed (sx-parse source)))
(when (not (empty? parsed))
(let ((result (list))
(step-ref (dict "v" 0)))
(split-tag (first parsed) result)
(reset! steps result)
(let ((tokens (list)))
(dict-set! step-ref "v" 0)
(build-code-tokens (first parsed) tokens step-ref 0)
(reset! code-tokens tokens))
;; Defer code DOM build until lake exists
(schedule-idle (fn ()
(build-code-dom)
;; Clear preview and replay to initial step-idx
(let ((preview (get-preview)))
(when preview (dom-set-prop preview "innerHTML" "")))
(let ((target (deref step-idx)))
(reset! step-idx 0)
(set-stack (list (get-preview)))
(for-each (fn (_) (do-step)) (slice (deref steps) 0 target)))
(update-code-highlight)
(run-post-render-hooks))))))))))
(div :class "space-y-4"
;; Code view lake — spans built imperatively, classes updated on step
(div (~cssx/tw :tokens "font-mono bg-stone-50 rounded p-2 overflow-x-auto leading-relaxed whitespace-pre-wrap")
@@ -241,4 +241,4 @@
"text-violet-300 cursor-not-allowed"))
"\u25b6"))
;; Live preview lake
(lake :id "home-preview")))))
(lake :id "home-preview"))))))

View File

@@ -60,7 +60,7 @@
(dict :label "Delivery" :href "/sx/(applications.(cssx.delivery))")
(dict :label "Async CSS" :href "/sx/(applications.(cssx.async))")
(dict :label "Live Styles" :href "/sx/(applications.(cssx.live))")
(dict :label "Comparisons" :href "/sx/(applications.(cssx.comparisons))")
(dict :label "Comparisons" :href "/sx/(applications.(cssx.comparison))")
(dict :label "Philosophy" :href "/sx/(applications.(cssx.philosophy))")))
(define essays-nav-items (list
@@ -252,25 +252,36 @@
(dict :label "Mother Language" :href "/sx/(etc.(plan.mother-language))"
:summary "SX as its own compiler. OCaml as substrate (closest to CEK), Koka as alternative (compile-time linearity), ultimately self-hosting. One language, every target.")))
(define reactive-examples-nav-items (list
{:label "Counter" :href "/sx/(geography.(reactive.(examples.counter)))"}
{:label "Temperature" :href "/sx/(geography.(reactive.(examples.temperature)))"}
{:label "Stopwatch" :href "/sx/(geography.(reactive.(examples.stopwatch)))"}
{:label "Imperative" :href "/sx/(geography.(reactive.(examples.imperative)))"}
{:label "Reactive List" :href "/sx/(geography.(reactive.(examples.reactive-list)))"}
{:label "Input Binding" :href "/sx/(geography.(reactive.(examples.input-binding)))"}
{:label "Portals" :href "/sx/(geography.(reactive.(examples.portal)))"}
{:label "Error Boundary" :href "/sx/(geography.(reactive.(examples.error-boundary)))"}
{:label "Refs" :href "/sx/(geography.(reactive.(examples.refs)))"}
{:label "Dynamic Class" :href "/sx/(geography.(reactive.(examples.dynamic-class)))"}
{:label "Resource" :href "/sx/(geography.(reactive.(examples.resource)))"}
{:label "Transitions" :href "/sx/(geography.(reactive.(examples.transition)))"}
{:label "Stores" :href "/sx/(geography.(reactive.(examples.stores)))"}
{:label "Event Bridge" :href "/sx/(geography.(reactive.(examples.event-bridge-demo)))"}
{:label "defisland" :href "/sx/(geography.(reactive.(examples.defisland)))"}
{:label "Tests" :href "/sx/(geography.(reactive.(examples.tests)))"}
{:label "Coverage" :href "/sx/(geography.(reactive.(examples.coverage)))"}))
(define reactive-islands-nav-items (list
(dict :label "Examples" :href "/sx/(geography.(reactive.examples))"
(dict :label "Examples" :href "/sx/(geography.(reactive.(examples)))"
:summary "Live interactive islands — click the buttons, type in the inputs."
:children (list
{:label "Counter" :href "/sx/(geography.(reactive.examples))#demo-counter"}
{:label "Temperature" :href "/sx/(geography.(reactive.examples))#demo-temperature"}
{:label "Stopwatch" :href "/sx/(geography.(reactive.examples))#demo-stopwatch"}
{:label "Reactive List" :href "/sx/(geography.(reactive.examples))#demo-reactive-list"}
{:label "Input Binding" :href "/sx/(geography.(reactive.examples))#demo-input-binding"}
{:label "Portals" :href "/sx/(geography.(reactive.examples))#demo-portal"}
{:label "Error Boundary" :href "/sx/(geography.(reactive.examples))#demo-error-boundary"}
{:label "Refs" :href "/sx/(geography.(reactive.examples))#demo-refs"}
{:label "Dynamic Class" :href "/sx/(geography.(reactive.examples))#demo-dynamic-class"}
{:label "Resource" :href "/sx/(geography.(reactive.examples))#demo-resource"}
{:label "Transitions" :href "/sx/(geography.(reactive.examples))#demo-transition"}
{:label "Shared Stores" :href "/sx/(geography.(reactive.examples))#demo-stores"}
{:label "Event Bridge" :href "/sx/(geography.(reactive.examples))#demo-event-bridge"}))
(dict :label "Marshes" :href "/sx/(geography.(reactive.marshes))"
:summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms, client state modifies hypermedia.")))
:children reactive-examples-nav-items)))
(define marshes-examples-nav-items (list
{:label "Hypermedia Feeds State" :href "/sx/(geography.(marshes.hypermedia-feeds))"}
{:label "Server Writes to Signals" :href "/sx/(geography.(marshes.server-signals))"}
{:label "sx-on-settle" :href "/sx/(geography.(marshes.on-settle))"}
{:label "Signal-Bound Triggers" :href "/sx/(geography.(marshes.signal-triggers))"}
{:label "Reactive View Transform" :href "/sx/(geography.(marshes.view-transform))"}))
(define bootstrappers-nav-items (list
(dict :label "Overview" :href "/sx/(language.(bootstrapper))")
@@ -417,7 +428,8 @@
{:label "Spreads" :href "/sx/(geography.(spreads))"
:summary "Child-to-parent communication across render boundaries — spread, collect!, reactive-spread, built on scopes."}
{:label "Marshes" :href "/sx/(geography.(marshes))"
:summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms reshape server content, client state modifies how hypermedia is interpreted."}
:summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms reshape server content, client state modifies how hypermedia is interpreted."
:children marshes-examples-nav-items}
{:label "Isomorphism" :href "/sx/(geography.(isomorphism))" :children isomorphism-nav-items}
{:label "CEK Machine" :href "/sx/(geography.(cek))" :children cek-nav-items})}
{:label "Language" :href "/sx/(language)"

View File

@@ -1,22 +1,51 @@
;; SX docs page functions — section + page dispatch for GraphSX URL routing.
;;
;; IMPORTANT: Page functions return QUOTED expressions (unevaluated ASTs).
;; The Python router evaluates these functions with async_eval to get the AST,
;; then passes it through _eval_slot (aser) for component expansion and HTML
;; tag handling. This two-phase approach is necessary because eval_expr doesn't
;; handle HTML tags — only the aser/render paths do.
;; Page functions return QUOTED expressions (unevaluated ASTs).
;; The router evaluates these via the OCaml kernel (or Python fallback).
;;
;; Pattern:
;; Simple: '(~component-name)
;; Data: (let ((data (helper))) `(~component :key ,val))
;; Data: (let ((data (helper "name" arg))) `(~component :key ,val))
;;
;; URL eval: /(language.(doc.introduction))
;; → (language (doc "introduction"))
;; → async_eval returns [Symbol("~docs-content/docs-introduction-content")]
;; → _eval_slot wraps in (~layouts/doc :path "..." <ast>) and renders via aser
;; IO: Application data is fetched via the (helper name ...) IO primitive,
;; which dispatches to Python page helpers through the coroutine bridge.
;; This keeps the spec clean — no application functions leak into the kernel.
;; ---------------------------------------------------------------------------
;; Convention-based page dispatch
;; ---------------------------------------------------------------------------
;;
;; NOTE: Lambda &rest is not supported by call-lambda in the current spec.
;; All functions take explicit positional params; missing args default to nil.
;; Most page functions are boilerplate: slug → component name via a naming
;; convention. Instead of hand-writing case statements, derive the component
;; symbol from the slug at runtime.
;;
;; Naming conventions:
;; essay: "sx-sucks" → ~essays/sx-sucks/essay-sx-sucks
;; plan: "status" → ~plans/status/plan-status-content
;; example: "tabs" → ~examples-content/example-tabs
;; protocol: "fragments" → ~protocols/fragments-content
;; cssx: "patterns" → ~cssx/patterns-content
;; ri-example: "counter" → ~reactive-islands/demo/example-counter
;; Build a component symbol from a slug and a naming pattern.
;; Pattern: prefix + slug + infix + slug + suffix
;; When infix is nil, slug appears once: prefix + slug + suffix
(define slug->component
(fn (slug prefix infix suffix)
(if infix
(make-symbol (str prefix slug infix slug suffix))
(make-symbol (str prefix slug suffix)))))
;; Make a simple slug-dispatcher: given a naming convention, returns a function
;; that maps (slug) → '(~derived-component-name).
;; default-name is a STRING of the component name for the nil-slug (index) case.
;; (We use a string + make-symbol because bare ~symbols get evaluated as lookups.)
(define make-page-fn
(fn (default-name prefix infix suffix)
(fn (slug)
(if (nil? slug)
(list (make-symbol default-name))
(list (slug->component slug prefix infix suffix))))))
;; ---------------------------------------------------------------------------
;; Section functions — structural, pass through content or return index
@@ -49,16 +78,15 @@
(if (nil? content) nil content)))
(define reactive
(fn (slug)
(if (nil? slug)
(fn (content)
(if (nil? content)
'(~reactive-islands/index/reactive-islands-index-content)
(case slug
"demo" '(~reactive-islands/demo/reactive-islands-demo-content)
"event-bridge" '(~reactive-islands/event-bridge/reactive-islands-event-bridge-content)
"named-stores" '(~reactive-islands/named-stores/reactive-islands-named-stores-content)
"plan" '(~reactive-islands/plan/reactive-islands-plan-content)
"phase2" '(~reactive-islands/phase2/reactive-islands-phase2-content)
:else '(~reactive-islands/index/reactive-islands-index-content)))))
content)))
;; Convention: ~reactive-islands/demo/example-{slug}
(define examples
(make-page-fn "~reactive-islands/demo/reactive-islands-demo-content" "~reactive-islands/demo/example-" nil ""))
(define cek
(fn (slug)
@@ -83,8 +111,16 @@
(if (nil? content) '(~geography/spreads-content) content)))
(define marshes
(fn (content)
(if (nil? content) '(~reactive-islands/marshes/reactive-islands-marshes-content) content)))
(fn (slug)
(if (nil? slug)
'(~reactive-islands/marshes/reactive-islands-marshes-content)
(case slug
"hypermedia-feeds" '(~reactive-islands/marshes/example-hypermedia-feeds)
"server-signals" '(~reactive-islands/marshes/example-server-signals)
"on-settle" '(~reactive-islands/marshes/example-on-settle)
"signal-triggers" '(~reactive-islands/marshes/example-signal-triggers)
"view-transform" '(~reactive-islands/marshes/example-view-transform)
:else '(~reactive-islands/marshes/reactive-islands-marshes-content)))))
(define isomorphism
(fn (slug)
@@ -92,7 +128,7 @@
'(~plans/isomorphic/plan-isomorphic-content)
(case slug
"bundle-analyzer"
(let ((data (bundle-analyzer-data)))
(let ((data (helper "bundle-analyzer-data")))
`(~analyzer/bundle-analyzer-content
:pages ,(get data "pages")
:total-components ,(get data "total-components")
@@ -100,7 +136,7 @@
:pure-count ,(get data "pure-count")
:io-count ,(get data "io-count")))
"routing-analyzer"
(let ((data (routing-analyzer-data)))
(let ((data (helper "routing-analyzer-data")))
`(~routing-analyzer/content
:pages ,(get data "pages")
:total-pages ,(get data "total-pages")
@@ -108,7 +144,7 @@
:server-count ,(get data "server-count")
:registry-sample ,(get data "registry-sample")))
"data-test"
(let ((data (data-test-data)))
(let ((data (helper "data-test-data")))
`(~data-test/content
:server-time ,(get data "server-time")
:items ,(get data "items")
@@ -116,17 +152,17 @@
:transport ,(get data "transport")))
"async-io" '(~async-io-demo/content)
"affinity"
(let ((data (affinity-demo-data)))
(let ((data (helper "affinity-demo-data")))
`(~affinity-demo/content
:components ,(get data "components")
:page-plans ,(get data "page-plans")))
"optimistic"
(let ((data (optimistic-demo-data)))
(let ((data (helper "optimistic-demo-data")))
`(~optimistic-demo/content
:items ,(get data "items")
:server-time ,(get data "server-time")))
"offline"
(let ((data (offline-demo-data)))
(let ((data (helper "offline-demo-data")))
`(~offline-demo/content
:notes ,(get data "notes")
:server-time ,(get data "server-time")))
@@ -148,11 +184,11 @@
"components" '(~docs-content/docs-components-content)
"evaluator" '(~docs-content/docs-evaluator-content)
"primitives"
(let ((data (primitives-data)))
(let ((data (helper "primitives-data")))
`(~docs-content/docs-primitives-content
:prims (~docs/primitives-tables :primitives ,data)))
"special-forms"
(let ((data (special-forms-data)))
(let ((data (helper "special-forms-data")))
`(~docs-content/docs-special-forms-content
:forms (~docs/special-forms-tables :forms ,data)))
"server-rendering" '(~docs-content/docs-server-rendering-content)
@@ -184,7 +220,7 @@
`(~specs/overview-content :spec-title "Extensions" :spec-files ,files))
:else (let ((found-spec (find-spec slug)))
(if found-spec
(let ((src (read-spec-file (get found-spec "filename"))))
(let ((src (helper "read-spec-file" (get found-spec "filename"))))
`(~specs/detail-content
:spec-title ,(get found-spec "title")
:spec-desc ,(get found-spec "desc")
@@ -200,7 +236,7 @@
'(~specs/architecture-content)
(let ((found-spec (find-spec slug)))
(if found-spec
(let ((data (spec-explorer-data
(let ((data (helper "spec-explorer-data"
(get found-spec "filename")
(get found-spec "title")
(get found-spec "desc"))))
@@ -216,7 +252,7 @@
(dict :title (get item "title") :desc (get item "desc")
:prose (get item "prose")
:filename (get item "filename") :href (str "/sx/(language.(spec." (get item "slug") "))")
:source (read-spec-file (get item "filename"))))
:source (helper "read-spec-file" (get item "filename"))))
items)))
;; Bootstrappers (under language)
@@ -224,7 +260,7 @@
(fn (slug)
(if (nil? slug)
'(~specs/bootstrappers-index-content)
(let ((data (bootstrapper-data slug)))
(let ((data (helper "bootstrapper-data" slug)))
(if (get data "bootstrapper-not-found")
`(~specs/not-found :slug ,slug)
(case slug
@@ -250,7 +286,7 @@
:bootstrapper-source ,(get data "bootstrapper-source")
:bootstrapped-output ,(get data "bootstrapped-output"))
"page-helpers"
(let ((ph-data (page-helpers-demo-data)))
(let ((ph-data (helper "page-helpers-demo-data")))
`(~page-helpers-demo/content
:sf-categories ,(get ph-data "sf-categories")
:sf-total ,(get ph-data "sf-total")
@@ -277,7 +313,7 @@
(define test
(fn (slug)
(if (nil? slug)
(let ((data (run-modular-tests "all")))
(let ((data (helper "run-modular-tests""all")))
`(~testing/overview-content
:server-results ,(get data "server-results")
:framework-source ,(get data "framework-source")
@@ -290,7 +326,7 @@
(case slug
"runners" '(~testing/runners-content)
:else
(let ((data (run-modular-tests slug)))
(let ((data (helper "run-modular-tests"slug)))
(case slug
"eval" `(~testing/spec-content
:spec-name "eval" :spec-title "Evaluator Tests"
@@ -342,7 +378,7 @@
(fn (slug)
(if (nil? slug)
'(~examples/reference-index-content)
(let ((data (reference-data slug)))
(let ((data (helper "reference-data" slug)))
(case slug
"attributes" `(~reference/attrs-content
:req-table (~docs/attr-table-from-data :title "Request Attributes" :attrs ,(get data "req-attrs"))
@@ -371,7 +407,7 @@
(if (nil? slug) nil
(case kind
"attributes"
(let ((data (attr-detail-data slug)))
(let ((data (helper "attr-detail-data" slug)))
(if (get data "attr-not-found")
`(~reference/attr-not-found :slug ,slug)
`(~reference/attr-detail-content
@@ -382,7 +418,7 @@
:handler-code ,(get data "attr-handler")
:wire-placeholder-id ,(get data "attr-wire-id"))))
"headers"
(let ((data (header-detail-data slug)))
(let ((data (helper "header-detail-data" slug)))
(if (get data "header-not-found")
`(~reference/attr-not-found :slug ,slug)
`(~reference/header-detail-content
@@ -392,7 +428,7 @@
:example-code ,(get data "header-example")
:demo ,(get data "header-demo"))))
"events"
(let ((data (event-detail-data slug)))
(let ((data (helper "event-detail-data" slug)))
(if (get data "event-not-found")
`(~reference/attr-not-found :slug ,slug)
`(~reference/event-detail-content
@@ -403,39 +439,11 @@
:else nil))))
;; Examples (under geography → hypermedia)
;; Convention: ~examples-content/example-{slug}
(define example
(fn (slug)
(if (nil? slug)
nil
(case slug
"click-to-load" '(~examples-content/example-click-to-load)
"form-submission" '(~examples-content/example-form-submission)
"polling" '(~examples-content/example-polling)
"delete-row" '(~examples-content/example-delete-row)
"inline-edit" '(~examples-content/example-inline-edit)
"oob-swaps" '(~examples-content/example-oob-swaps)
"lazy-loading" '(~examples-content/example-lazy-loading)
"infinite-scroll" '(~examples-content/example-infinite-scroll)
"progress-bar" '(~examples-content/example-progress-bar)
"active-search" '(~examples-content/example-active-search)
"inline-validation" '(~examples-content/example-inline-validation)
"value-select" '(~examples-content/example-value-select)
"reset-on-submit" '(~examples-content/example-reset-on-submit)
"edit-row" '(~examples-content/example-edit-row)
"bulk-update" '(~examples-content/example-bulk-update)
"swap-positions" '(~examples-content/example-swap-positions)
"select-filter" '(~examples-content/example-select-filter)
"tabs" '(~examples-content/example-tabs)
"animations" '(~examples-content/example-animations)
"dialogs" '(~examples-content/example-dialogs)
"keyboard-shortcuts" '(~examples-content/example-keyboard-shortcuts)
"put-patch" '(~examples-content/example-put-patch)
"json-encoding" '(~examples-content/example-json-encoding)
"vals-and-headers" '(~examples-content/example-vals-and-headers)
"loading-states" '(~examples-content/example-loading-states)
"sync-replace" '(~examples-content/example-sync-replace)
"retry" '(~examples-content/example-retry)
:else '(~examples-content/example-click-to-load)))))
(if (nil? slug) nil
(list (slug->component slug "~examples-content/example-" nil "")))))
;; SX URLs (under applications)
(define sx-urls
@@ -443,59 +451,19 @@
'(~sx-urls/urls-content)))
;; CSSX (under applications)
;; Convention: ~cssx/{slug}-content
(define cssx
(fn (slug)
(if (nil? slug)
'(~cssx/overview-content)
(case slug
"patterns" '(~cssx/patterns-content)
"delivery" '(~cssx/delivery-content)
"async" '(~cssx/async-content)
"live" '(~cssx/live-content)
"comparisons" '(~cssx/comparison-content)
"philosophy" '(~cssx/philosophy-content)
:else '(~cssx/overview-content)))))
(make-page-fn "~cssx/overview-content" "~cssx/" nil "-content"))
;; Protocols (under applications)
;; Convention: ~protocols/{slug}-content
(define protocol
(fn (slug)
(if (nil? slug)
'(~protocols/wire-format-content)
(case slug
"wire-format" '(~protocols/wire-format-content)
"fragments" '(~protocols/fragments-content)
"resolver-io" '(~protocols/resolver-io-content)
"internal-services" '(~protocols/internal-services-content)
"activitypub" '(~protocols/activitypub-content)
"future" '(~protocols/future-content)
:else '(~protocols/wire-format-content)))))
(make-page-fn "~protocols/wire-format-content" "~protocols/" nil "-content"))
;; Essays (under etc)
;; Convention: ~essays/{slug}/essay-{slug}
(define essay
(fn (slug)
(if (nil? slug)
'(~essays/index/essays-index-content)
(case slug
"sx-sucks" '(~essays/sx-sucks/essay-sx-sucks)
"why-sexps" '(~essays/why-sexps/essay-why-sexps)
"htmx-react-hybrid" '(~essays/htmx-react-hybrid/essay-htmx-react-hybrid)
"on-demand-css" '(~essays/on-demand-css/essay-on-demand-css)
"client-reactivity" '(~essays/client-reactivity/essay-client-reactivity)
"sx-native" '(~essays/sx-native/essay-sx-native)
"tail-call-optimization" '(~essays/tail-call-optimization/essay-tail-call-optimization)
"continuations" '(~essays/continuations/essay-continuations)
"reflexive-web" '(~essays/reflexive-web/essay-reflexive-web)
"server-architecture" '(~essays/server-architecture/essay-server-architecture)
"separation-of-concerns" '(~essays/separation-of-concerns/essay-separation-of-concerns)
"sx-and-ai" '(~essays/sx-and-ai/essay-sx-and-ai)
"no-alternative" '(~essays/no-alternative/essay-no-alternative)
"zero-tooling" '(~essays/zero-tooling/essay-zero-tooling)
"react-is-hypermedia" '(~essays/react-is-hypermedia/essay-react-is-hypermedia)
"hegelian-synthesis" '(~essays/hegelian-synthesis/essay-hegelian-synthesis)
"the-art-chain" '(~essays/the-art-chain/essay-the-art-chain)
"self-defining-medium" '(~essays/self-defining-medium/essay-self-defining-medium)
"hypermedia-age-of-ai" '(~essays/hypermedia-age-of-ai/essay-hypermedia-age-of-ai)
:else '(~essays/index/essays-index-content)))))
(make-page-fn "~essays/index/essays-index-content" "~essays/" "/essay-" ""))
;; Philosophy (under etc)
(define philosophy
@@ -512,47 +480,6 @@
:else '(~essays/philosophy-index/content)))))
;; Plans (under etc)
;; Convention: ~plans/{slug}/plan-{slug}-content
(define plan
(fn (slug)
(if (nil? slug)
'(~plans/index/plans-index-content)
(case slug
"status" '(~plans/status/plan-status-content)
"reader-macros" '(~plans/reader-macros/plan-reader-macros-content)
"reader-macro-demo" '(~plans/reader-macro-demo/plan-reader-macro-demo-content)
"theorem-prover"
(let ((data (prove-data)))
'(~plans/theorem-prover/plan-theorem-prover-content))
"self-hosting-bootstrapper" '(~plans/self-hosting-bootstrapper/plan-self-hosting-bootstrapper-content)
"js-bootstrapper" '(~plans/js-bootstrapper/plan-js-bootstrapper-content)
"sx-activity" '(~plans/sx-activity/plan-sx-activity-content)
"predictive-prefetch" '(~plans/predictive-prefetch/plan-predictive-prefetch-content)
"content-addressed-components" '(~plans/content-addressed-components/plan-content-addressed-components-content)
"environment-images" '(~plans/environment-images/plan-environment-images-content)
"runtime-slicing" '(~plans/runtime-slicing/plan-runtime-slicing-content)
"typed-sx" '(~plans/typed-sx/plan-typed-sx-content)
"nav-redesign" '(~plans/nav-redesign/plan-nav-redesign-content)
"fragment-protocol" '(~plans/fragment-protocol/plan-fragment-protocol-content)
"glue-decoupling" '(~plans/glue-decoupling/plan-glue-decoupling-content)
"social-sharing" '(~plans/social-sharing/plan-social-sharing-content)
"sx-ci" '(~plans/sx-ci/plan-sx-ci-content)
"live-streaming" '(~plans/live-streaming/plan-live-streaming-content)
"sx-web-platform" '(~plans/sx-web-platform/plan-sx-web-platform-content)
"sx-forge" '(~plans/sx-forge/plan-sx-forge-content)
"sx-swarm" '(~plans/sx-swarm/plan-sx-swarm-content)
"sx-proxy" '(~plans/sx-proxy/plan-sx-proxy-content)
"mother-language" '(~plans/mother-language/plan-mother-language-content)
"isolated-evaluator" '(~plans/isolated-evaluator/plan-isolated-evaluator-content)
"rust-wasm-host" '(~plans/rust-wasm-host/plan-rust-wasm-host-content)
"async-eval-convergence" '(~plans/async-eval-convergence/plan-async-eval-convergence-content)
"wasm-bytecode-vm" '(~plans/wasm-bytecode-vm/plan-wasm-bytecode-vm-content)
"generative-sx" '(~plans/generative-sx/plan-generative-sx-content)
"art-dag-sx" '(~plans/art-dag-sx/plan-art-dag-sx-content)
"spec-explorer" '(~plans/spec-explorer/plan-spec-explorer-content)
"sx-urls" '(~plans/sx-urls/plan-sx-urls-content)
"sx-protocol" '(~plans/sx-protocol/plan-sx-protocol-content)
"scoped-effects" '(~plans/scoped-effects/plan-scoped-effects-content)
"foundations" '(~plans/foundations/plan-foundations-content)
"cek-reactive" '(~plans/cek-reactive/plan-cek-reactive-content)
"reactive-runtime" '(~plans/reactive-runtime/plan-reactive-runtime-content)
:else '(~plans/index/plans-index-content)))))
(make-page-fn "~plans/index/plans-index-content" "~plans/" "/plan-" "-content"))

View File

@@ -1,192 +1,218 @@
;; ---------------------------------------------------------------------------
;; Examples page — live interactive islands, one per section
;; Examples — individual reactive island demo pages
;; ---------------------------------------------------------------------------
;; Overview page — summary with links to individual examples
(defcomp ~reactive-islands/demo/reactive-islands-demo-content ()
(~docs/page :title "Reactive Islands — Examples"
(~docs/section :title "Live interactive islands" :id "intro"
(p (strong "Every example below is a live interactive island") " — not a static code snippet. Click the buttons, type in the inputs. The signal runtime is defined in " (code "signals.sx") ", bootstrapped to JavaScript. No hand-written signal logic.")
(p "Each island uses " (code "defisland") " with signals (" (code "signal") ", " (code "deref") ", " (code "reset!") ", " (code "swap!") "), derived values (" (code "computed") "), side effects (" (code "effect") "), and batch updates (" (code "batch") ")."))
(~docs/section :title "1. Signal + Computed + Effect" :id "demo-counter"
(p "A signal holds a value. A computed derives from it. Click the buttons — the counter and doubled value update instantly, no server round-trip.")
(~reactive-islands/index/demo-counter :initial 0)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! count dec)) \"\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p \"doubled: \" (deref doubled)))))" "lisp"))
(p (code "(deref count)") " in a text position creates a reactive text node. When " (code "count") " changes, " (em "only that text node") " updates. " (code "doubled") " recomputes automatically. No diffing."))
(~docs/section :title "2. Temperature Converter" :id "demo-temperature"
(p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.")
(~reactive-islands/index/demo-temperature)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/temperature ()\n (let ((celsius (signal 20)))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! celsius (fn (c) (- c 5)))) \"5\")\n (span (deref celsius))\n (button :on-click (fn (e) (swap! celsius (fn (c) (+ c 5)))) \"+5\")\n (span \"°C = \")\n (span (+ (* (deref celsius) 1.8) 32))\n (span \"°F\"))))" "lisp"))
(p "The actual implementation uses " (code "computed") " for Fahrenheit: " (code "(computed (fn () (+ (* (deref celsius) 1.8) 32)))") ". The " (code "(deref fahrenheit)") " in the span creates a reactive text node that updates when celsius changes."))
(~docs/section :title "3. Effect + Cleanup: Stopwatch" :id "demo-stopwatch"
(p "Effects can return cleanup functions. This stopwatch starts a " (code "set-interval") " — the cleanup clears it when the running signal toggles off.")
(~reactive-islands/index/demo-stopwatch)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/stopwatch ()\n (let ((running (signal false))\n (elapsed (signal 0))\n (time-text (create-text-node \"0.0s\"))\n (btn-text (create-text-node \"Start\")))\n ;; Timer: effect creates interval, cleanup clears it\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))\n ;; Display: updates text node when elapsed changes\n (effect (fn ()\n (let ((e (deref elapsed)))\n (dom-set-text-content time-text\n (str (floor (/ e 10)) \".\" (mod e 10) \"s\")))))\n ;; Button label\n (effect (fn ()\n (dom-set-text-content btn-text\n (if (deref running) \"Stop\" \"Start\"))))\n (div :class \"...\"\n (span time-text)\n (button :on-click (fn (e) (swap! running not)) btn-text)\n (button :on-click (fn (e)\n (reset! running false) (reset! elapsed 0)) \"Reset\"))))" "lisp"))
(p "Three effects, each tracking different signals. The timer effect's cleanup fires before each re-run — toggling " (code "running") " off clears the interval. No hook rules: effects can appear anywhere, in any order."))
(~docs/section :title "4. Imperative Pattern" :id "demo-imperative"
(p "For complex reactivity (dynamic classes, conditional text), use the imperative pattern: " (code "create-text-node") " + " (code "effect") " + " (code "dom-set-text-content") ".")
(~reactive-islands/index/demo-imperative)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp"))
(p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates."))
(~docs/section :title "5. Reactive List" :id "demo-reactive-list"
(p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.")
(~reactive-islands/index/demo-reactive-list)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp"))
(p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass."))
(~docs/section :title "6. Input Binding" :id "demo-input-binding"
(p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.")
(~reactive-islands/index/demo-input-binding)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp"))
(p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump."))
(~docs/section :title "7. Portals" :id "demo-portal"
(p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.")
(~reactive-islands/index/demo-portal)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp"))
(p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup."))
(~docs/section :title "8. Error Boundaries" :id "demo-error-boundary"
(p "When an island's rendering or effect throws, " (code "error-boundary") " catches the error and renders a fallback. The fallback receives the error and a retry function. Partial effects created before the error are disposed automatically.")
(~reactive-islands/index/demo-error-boundary)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/error-boundary ()\n (let ((throw? (signal false)))\n (error-boundary\n ;; Fallback: receives (err retry-fn)\n (fn (err retry-fn)\n (div :class \"p-3 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700\" (error-message err))\n (button :on-click (fn (e)\n (reset! throw? false) (invoke retry-fn))\n \"Retry\")))\n ;; Children: the happy path\n (do\n (when (deref throw?) (error \"Intentional explosion!\"))\n (p \"Everything is fine.\")))))" "lisp"))
(p "React equivalent: " (code "componentDidCatch") " / " (code "ErrorBoundary") ". SX's version is simpler — one form, not a class. The " (code "error-boundary") " form is a render-dom special form in " (code "adapter-dom.sx") "."))
(~docs/section :title "9. Refs — Imperative DOM Access" :id "demo-refs"
(p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.")
(~reactive-islands/index/demo-refs)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/refs ()\n (let ((my-ref (dict \"current\" nil))\n (msg (signal \"\")))\n (input :ref my-ref :type \"text\"\n :placeholder \"I can be focused programmatically\")\n (button :on-click (fn (e)\n (dom-focus (get my-ref \"current\")))\n \"Focus Input\")\n (button :on-click (fn (e)\n (let ((el (get my-ref \"current\")))\n (reset! msg (str \"value: \" (dom-get-prop el \"value\")))))\n \"Read Input\")\n (when (not (= (deref msg) \"\"))\n (p (deref msg)))))" "lisp"))
(p "React equivalent: " (code "useRef") ". In SX, a ref is just " (code "(dict \"current\" nil)") " — no special API. The " (code ":ref") " attribute sets " (code "(dict-set! ref \"current\" el)") " when the element is created. Read it with " (code "(get ref \"current\")") "."))
(~docs/section :title "10. Dynamic Class and Style" :id "demo-dynamic-class"
(p "React uses " (code "className") " and " (code "style") " props with state. SX does the same — " (code "(deref signal)") " inside a " (code ":class") " or " (code ":style") " attribute creates a reactive binding. The attribute updates when the signal changes.")
(~reactive-islands/index/demo-dynamic-class)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/dynamic-class ()\n (let ((danger (signal false))\n (size (signal 16)))\n (div\n (button :on-click (fn (e) (swap! danger not))\n (if (deref danger) \"Safe mode\" \"Danger mode\"))\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 2))))\n \"Bigger\")\n ;; Reactive class — recomputed when danger changes\n (div :class (str \"p-3 rounded font-medium \"\n (if (deref danger)\n \"bg-red-100 text-red-800\"\n \"bg-green-100 text-green-800\"))\n ;; Reactive style — recomputed when size changes\n :style (str \"font-size:\" (deref size) \"px\")\n \"This element's class and style are reactive.\"))))" "lisp"))
(p "React equivalent: " (code "className={danger ? 'red' : 'green'}") " and " (code "style={{fontSize: size}}") ". In SX the " (code "str") " + " (code "if") " + " (code "deref") " pattern handles it — no " (code "classnames") " library needed. For complex conditional classes, use a " (code "computed") " or a CSSX " (code "defcomp") " that returns a class string."))
(~docs/section :title "11. Resource + Suspense Pattern" :id "demo-resource"
(p (code "resource") " wraps an async operation into a signal with " (code "loading") "/" (code "data") "/" (code "error") " states. Combined with " (code "cond") " + " (code "deref") ", this is the suspense pattern — no special form needed.")
(~reactive-islands/index/demo-resource)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/resource ()\n (let ((data (resource (fn ()\n ;; Any promise-returning function\n (promise-delayed 1500\n (dict \"name\" \"Ada Lovelace\"\n \"role\" \"First Programmer\"))))))\n ;; This IS the suspense pattern:\n (let ((state (deref data)))\n (cond\n (get state \"loading\")\n (div \"Loading...\")\n (get state \"error\")\n (div \"Error: \" (get state \"error\"))\n :else\n (div (get (get state \"data\") \"name\"))))))" "lisp"))
(p "React equivalent: " (code "Suspense") " + " (code "use()") " or " (code "useSWR") ". SX doesn't need a special " (code "suspense") " form because " (code "resource") " returns a signal and " (code "cond") " + " (code "deref") " creates reactive conditional rendering. When the promise resolves, the signal updates and the " (code "cond") " branch switches automatically."))
(~docs/section :title "12. Transition Pattern" :id "demo-transition"
(p "React's " (code "startTransition") " defers non-urgent updates so typing stays responsive. In SX: " (code "schedule-idle") " + " (code "batch") ". The filter runs during idle time, not blocking the input event.")
(~reactive-islands/index/demo-transition)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/transition ()\n (let ((query (signal \"\"))\n (all-items (list \"Signals\" \"Effects\" ...))\n (filtered (signal (list)))\n (pending (signal false)))\n (reset! filtered all-items)\n ;; Filter effect — deferred via schedule-idle\n (effect (fn ()\n (let ((q (lower (deref query))))\n (if (= q \"\")\n (do (reset! pending false)\n (reset! filtered all-items))\n (do (reset! pending true)\n (schedule-idle (fn ()\n (batch (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower item) q))\n all-items))\n (reset! pending false))))))))))\n (div\n (input :bind query :placeholder \"Filter...\")\n (when (deref pending) (span \"Filtering...\"))\n (ul (map (fn (item) (li :key item item))\n (deref filtered))))))" "lisp"))
(p "React equivalent: " (code "startTransition(() => setFiltered(...))") ". SX uses " (code "schedule-idle") " (" (code "requestIdleCallback") " under the hood) to defer the expensive " (code "filter") " operation, and " (code "batch") " to group the result into one update. Fine-grained signals already avoid the jank that makes transitions critical in React — this pattern is for truly expensive computations."))
(~docs/section :title "13. Shared Stores" :id "demo-stores"
(p "React uses " (code "Context") " or state management libraries for cross-component state. SX uses " (code "def-store") " / " (code "use-store") " — named signal containers that persist across island creation/destruction.")
(~reactive-islands/index/demo-store-writer)
(~reactive-islands/index/demo-store-reader)
(~docs/code :code (highlight ";; Island A — creates/writes the store\n(defisland ~reactive-islands/demo/store-writer ()\n (let ((store (def-store \"theme\" (fn ()\n (dict \"color\" (signal \"violet\")\n \"dark\" (signal false))))))\n (select :bind (get store \"color\")\n (option :value \"violet\" \"Violet\")\n (option :value \"blue\" \"Blue\"))\n (input :type \"checkbox\" :bind (get store \"dark\"))))\n\n;; Island B — reads the same store, different island\n(defisland ~reactive-islands/demo/store-reader ()\n (let ((store (use-store \"theme\")))\n (div :class (str \"bg-\" (deref (get store \"color\")) \"-100\")\n \"Styled by signals from Island A\")))" "lisp"))
(p "React equivalent: " (code "createContext") " + " (code "useContext") " or Redux/Zustand. Stores are simpler — just named dicts of signals at page scope. " (code "def-store") " creates once, " (code "use-store") " retrieves. Stores survive island disposal but clear on full page navigation."))
(~docs/section :title "14. Event Bridge" :id "demo-event-bridge"
(p "Server-rendered content inside an island (an htmx \"lake\") can communicate with island signals via DOM custom events. Buttons with " (code "data-sx-emit") " dispatch events that island effects catch.")
(~reactive-islands/index/demo-event-bridge)
(~docs/code :code (highlight ";; Island listens for custom events from server-rendered content\n(defisland ~reactive-islands/demo/event-bridge ()\n (let ((messages (signal (list))))\n ;; Bridge: auto-listen for \"inbox:message\" events\n (bridge-event container \"inbox:message\" messages\n (fn (detail) (append (deref messages) (get detail \"text\"))))\n (div\n ;; Lake content (server-rendered) has data-sx-emit buttons\n (div :id \"lake\"\n :sx-get \"/my-content\"\n :sx-swap \"innerHTML\"\n :sx-trigger \"load\")\n ;; Island reads the signal reactively\n (ul (map (fn (msg) (li msg)) (deref messages))))))" "lisp"))
(p "The " (code "data-sx-emit") " attribute is processed by the client engine — it adds a click handler that dispatches a CustomEvent with the JSON from " (code "data-sx-emit-detail") ". The event bubbles up to the island container where " (code "bridge-event") " catches it."))
(~docs/section :title "15. How defisland Works" :id "how-defisland"
(p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with a reactive boundary. Inside an island, " (code "deref") " subscribes DOM nodes to signals.")
(~docs/code :code (highlight ";; Definition — same syntax as defcomp\n(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div\n (span (deref count)) ;; reactive text node\n (button :on-click (fn (e) (swap! count inc)) ;; event handler\n \"+\"))))\n\n;; Usage — same as any component\n(~reactive-islands/demo/counter :initial 42)\n\n;; Server-side rendering:\n;; <div data-sx-island=\"counter\" data-sx-state='{\"initial\":42}'>\n;; <span>42</span><button>+</button>\n;; </div>\n;;\n;; Client hydrates: signals + effects + event handlers attach" "lisp"))
(p "Each " (code "deref") " call registers the enclosing DOM node as a subscriber. Signal changes update " (em "only") " the subscribed nodes — no virtual DOM, no diffing, no component re-renders."))
(~docs/section :title "16. Test suite" :id "demo-tests"
(p "17 tests verify the signal runtime against the spec. All pass in the Python test runner (which uses the hand-written evaluator with native platform primitives).")
(~docs/code :code (highlight ";; Signal basics (6 tests)\n(assert-true (signal? (signal 42)))\n(assert-equal 42 (deref (signal 42)))\n(assert-equal 5 (deref 5)) ;; non-signal passthrough\n\n;; reset! changes value\n(let ((s (signal 0)))\n (reset! s 10)\n (assert-equal 10 (deref s)))\n\n;; reset! does NOT notify when value unchanged (identical? check)\n\n;; Computed (3 tests)\n(let ((a (signal 3)) (b (signal 4))\n (sum (computed (fn () (+ (deref a) (deref b))))))\n (assert-equal 7 (deref sum))\n (reset! a 10)\n (assert-equal 14 (deref sum)))\n\n;; Effects (4 tests) — immediate run, re-run on change, dispose, cleanup\n;; Batch (1 test) — defers notifications, deduplicates subscribers\n;; defisland (3 tests) — creates island, callable, accepts children" "lisp"))
(p :class "mt-2 text-sm text-stone-500" "Run: " (code "python3 shared/sx/tests/run.py signals")))
(~docs/section :title "React Feature Coverage" :id "coverage"
(p "Every React feature has an SX equivalent — most are simpler because signals are fine-grained.")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "React")
(th :class "px-3 py-2 font-medium text-stone-600" "SX")
(th :class "px-3 py-2 font-medium text-stone-600" "Demo")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useState")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(signal value)")
(td :class "px-3 py-2 text-xs text-stone-500" "#1"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useMemo")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(computed (fn () ...))")
(td :class "px-3 py-2 text-xs text-stone-500" "#1, #2"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useEffect")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(effect (fn () ...))")
(td :class "px-3 py-2 text-xs text-stone-500" "#3"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useRef")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(dict \"current\" nil) + :ref")
(td :class "px-3 py-2 text-xs text-stone-500" "#9"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useCallback")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(fn (...) ...) — no dep arrays")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "className / style")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":class (str ...) :style (str ...)")
(td :class "px-3 py-2 text-xs text-stone-500" "#10"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Controlled inputs")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":bind signal")
(td :class "px-3 py-2 text-xs text-stone-500" "#6"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "key prop")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":key value")
(td :class "px-3 py-2 text-xs text-stone-500" "#5"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "createPortal")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(portal \"#target\" ...)")
(td :class "px-3 py-2 text-xs text-stone-500" "#7"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "ErrorBoundary")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(error-boundary fallback ...)")
(td :class "px-3 py-2 text-xs text-stone-500" "#8"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Suspense + use()")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(resource fn) + cond/deref")
(td :class "px-3 py-2 text-xs text-stone-500" "#11"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "startTransition")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "schedule-idle + batch")
(td :class "px-3 py-2 text-xs text-stone-500" "#12"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Context / Redux")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "def-store / use-store")
(td :class "px-3 py-2 text-xs text-stone-500" "#13"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Virtual DOM / diffing")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained signals update exact DOM nodes")
(td :class "px-3 py-2 text-xs text-stone-500" ""))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "JSX / build step")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — s-expressions are the syntax")
(td :class "px-3 py-2 text-xs text-stone-500" ""))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Server Components")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — aser mode already expands server-side")
(td :class "px-3 py-2 text-xs text-stone-500" ""))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Concurrent rendering")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained updates are inherently incremental")
(td :class "px-3 py-2 text-xs text-stone-500" ""))
(tr
(td :class "px-3 py-2 text-stone-700" "Hooks rules")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — signals are values, no ordering rules")
(td :class "px-3 py-2 text-xs text-stone-500" ""))))))))
(~docs/section :title "Examples" :id "examples"
(ol :class "space-y-1"
(map (fn (item)
(li (a :href (get item "href")
:sx-get (get item "href") :sx-target "#main-panel"
:sx-select "#main-panel" :sx-swap "outerHTML"
:sx-push-url "true"
:class "text-violet-600 hover:underline"
(get item "label"))))
reactive-examples-nav-items)))))
;; ---------------------------------------------------------------------------
;; Event Bridge — DOM events for lake→island communication
;; Individual example pages
;; ---------------------------------------------------------------------------
(defcomp ~reactive-islands/demo/example-counter ()
(~docs/page :title "Signal + Computed + Effect"
(p "A signal holds a value. A computed derives from it. Click the buttons — the counter and doubled value update instantly, no server round-trip.")
(~reactive-islands/index/demo-counter :initial 0)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! count dec)) \"\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p \"doubled: \" (deref doubled)))))" "lisp"))
(p (code "(deref count)") " in a text position creates a reactive text node. When " (code "count") " changes, " (em "only that text node") " updates. " (code "doubled") " recomputes automatically. No diffing.")))
(defcomp ~reactive-islands/demo/example-temperature ()
(~docs/page :title "Temperature Converter"
(p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.")
(~reactive-islands/index/demo-temperature)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/temperature ()\n (let ((celsius (signal 20)))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! celsius (fn (c) (- c 5)))) \"5\")\n (span (deref celsius))\n (button :on-click (fn (e) (swap! celsius (fn (c) (+ c 5)))) \"+5\")\n (span \"°C = \")\n (span (+ (* (deref celsius) 1.8) 32))\n (span \"°F\"))))" "lisp"))
(p "The actual implementation uses " (code "computed") " for Fahrenheit: " (code "(computed (fn () (+ (* (deref celsius) 1.8) 32)))") ". The " (code "(deref fahrenheit)") " in the span creates a reactive text node that updates when celsius changes.")))
(defcomp ~reactive-islands/demo/example-stopwatch ()
(~docs/page :title "Effect + Cleanup: Stopwatch"
(p "Effects can return cleanup functions. This stopwatch starts a " (code "set-interval") " — the cleanup clears it when the running signal toggles off.")
(~reactive-islands/index/demo-stopwatch)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/stopwatch ()\n (let ((running (signal false))\n (elapsed (signal 0))\n (time-text (create-text-node \"0.0s\"))\n (btn-text (create-text-node \"Start\")))\n ;; Timer: effect creates interval, cleanup clears it\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))\n ;; Display: updates text node when elapsed changes\n (effect (fn ()\n (let ((e (deref elapsed)))\n (dom-set-text-content time-text\n (str (floor (/ e 10)) \".\" (mod e 10) \"s\")))))\n ;; Button label\n (effect (fn ()\n (dom-set-text-content btn-text\n (if (deref running) \"Stop\" \"Start\"))))\n (div :class \"...\"\n (span time-text)\n (button :on-click (fn (e) (swap! running not)) btn-text)\n (button :on-click (fn (e)\n (reset! running false) (reset! elapsed 0)) \"Reset\"))))" "lisp"))
(p "Three effects, each tracking different signals. The timer effect's cleanup fires before each re-run — toggling " (code "running") " off clears the interval. No hook rules: effects can appear anywhere, in any order.")))
(defcomp ~reactive-islands/demo/example-imperative ()
(~docs/page :title "Imperative Pattern"
(p "For complex reactivity (dynamic classes, conditional text), use the imperative pattern: " (code "create-text-node") " + " (code "effect") " + " (code "dom-set-text-content") ".")
(~reactive-islands/index/demo-imperative)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp"))
(p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates.")))
(defcomp ~reactive-islands/demo/example-reactive-list ()
(~docs/page :title "Reactive List"
(p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.")
(~reactive-islands/index/demo-reactive-list)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp"))
(p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass.")))
(defcomp ~reactive-islands/demo/example-input-binding ()
(~docs/page :title "Input Binding"
(p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.")
(~reactive-islands/index/demo-input-binding)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp"))
(p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump.")))
(defcomp ~reactive-islands/demo/example-portal ()
(~docs/page :title "Portals"
(p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.")
(~reactive-islands/index/demo-portal)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp"))
(p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup.")))
(defcomp ~reactive-islands/demo/example-error-boundary ()
(~docs/page :title "Error Boundaries"
(p "When an island's rendering or effect throws, " (code "error-boundary") " catches the error and renders a fallback. The fallback receives the error and a retry function. Partial effects created before the error are disposed automatically.")
(~reactive-islands/index/demo-error-boundary)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/error-boundary ()\n (let ((throw? (signal false)))\n (error-boundary\n ;; Fallback: receives (err retry-fn)\n (fn (err retry-fn)\n (div :class \"p-3 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700\" (error-message err))\n (button :on-click (fn (e)\n (reset! throw? false) (invoke retry-fn))\n \"Retry\")))\n ;; Children: the happy path\n (do\n (when (deref throw?) (error \"Intentional explosion!\"))\n (p \"Everything is fine.\")))))" "lisp"))
(p "React equivalent: " (code "componentDidCatch") " / " (code "ErrorBoundary") ". SX's version is simpler — one form, not a class. The " (code "error-boundary") " form is a render-dom special form in " (code "adapter-dom.sx") ".")))
(defcomp ~reactive-islands/demo/example-refs ()
(~docs/page :title "Refs — Imperative DOM Access"
(p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.")
(~reactive-islands/index/demo-refs)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/refs ()\n (let ((my-ref (dict \"current\" nil))\n (msg (signal \"\")))\n (input :ref my-ref :type \"text\"\n :placeholder \"I can be focused programmatically\")\n (button :on-click (fn (e)\n (dom-focus (get my-ref \"current\")))\n \"Focus Input\")\n (button :on-click (fn (e)\n (let ((el (get my-ref \"current\")))\n (reset! msg (str \"value: \" (dom-get-prop el \"value\")))))\n \"Read Input\")\n (when (not (= (deref msg) \"\"))\n (p (deref msg)))))" "lisp"))
(p "React equivalent: " (code "useRef") ". In SX, a ref is just " (code "(dict \"current\" nil)") " — no special API. The " (code ":ref") " attribute sets " (code "(dict-set! ref \"current\" el)") " when the element is created. Read it with " (code "(get ref \"current\")") ".")))
(defcomp ~reactive-islands/demo/example-dynamic-class ()
(~docs/page :title "Dynamic Class and Style"
(p "React uses " (code "className") " and " (code "style") " props with state. SX does the same — " (code "(deref signal)") " inside a " (code ":class") " or " (code ":style") " attribute creates a reactive binding. The attribute updates when the signal changes.")
(~reactive-islands/index/demo-dynamic-class)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/dynamic-class ()\n (let ((danger (signal false))\n (size (signal 16)))\n (div\n (button :on-click (fn (e) (swap! danger not))\n (if (deref danger) \"Safe mode\" \"Danger mode\"))\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 2))))\n \"Bigger\")\n ;; Reactive class — recomputed when danger changes\n (div :class (str \"p-3 rounded font-medium \"\n (if (deref danger)\n \"bg-red-100 text-red-800\"\n \"bg-green-100 text-green-800\"))\n ;; Reactive style — recomputed when size changes\n :style (str \"font-size:\" (deref size) \"px\")\n \"This element's class and style are reactive.\"))))" "lisp"))
(p "React equivalent: " (code "className={danger ? 'red' : 'green'}") " and " (code "style={{fontSize: size}}") ". In SX the " (code "str") " + " (code "if") " + " (code "deref") " pattern handles it — no " (code "classnames") " library needed. For complex conditional classes, use a " (code "computed") " or a CSSX " (code "defcomp") " that returns a class string.")))
(defcomp ~reactive-islands/demo/example-resource ()
(~docs/page :title "Resource + Suspense Pattern"
(p (code "resource") " wraps an async operation into a signal with " (code "loading") "/" (code "data") "/" (code "error") " states. Combined with " (code "cond") " + " (code "deref") ", this is the suspense pattern — no special form needed.")
(~reactive-islands/index/demo-resource)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/resource ()\n (let ((data (resource (fn ()\n ;; Any promise-returning function\n (promise-delayed 1500\n (dict \"name\" \"Ada Lovelace\"\n \"role\" \"First Programmer\"))))))\n ;; This IS the suspense pattern:\n (let ((state (deref data)))\n (cond\n (get state \"loading\")\n (div \"Loading...\")\n (get state \"error\")\n (div \"Error: \" (get state \"error\"))\n :else\n (div (get (get state \"data\") \"name\"))))))" "lisp"))
(p "React equivalent: " (code "Suspense") " + " (code "use()") " or " (code "useSWR") ". SX doesn't need a special " (code "suspense") " form because " (code "resource") " returns a signal and " (code "cond") " + " (code "deref") " creates reactive conditional rendering. When the promise resolves, the signal updates and the " (code "cond") " branch switches automatically.")))
(defcomp ~reactive-islands/demo/example-transition ()
(~docs/page :title "Transition Pattern"
(p "React's " (code "startTransition") " defers non-urgent updates so typing stays responsive. In SX: " (code "schedule-idle") " + " (code "batch") ". The filter runs during idle time, not blocking the input event.")
(~reactive-islands/index/demo-transition)
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/transition ()\n (let ((query (signal \"\"))\n (all-items (list \"Signals\" \"Effects\" ...))\n (filtered (signal (list)))\n (pending (signal false)))\n (reset! filtered all-items)\n ;; Filter effect — deferred via schedule-idle\n (effect (fn ()\n (let ((q (lower (deref query))))\n (if (= q \"\")\n (do (reset! pending false)\n (reset! filtered all-items))\n (do (reset! pending true)\n (schedule-idle (fn ()\n (batch (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower item) q))\n all-items))\n (reset! pending false))))))))))\n (div\n (input :bind query :placeholder \"Filter...\")\n (when (deref pending) (span \"Filtering...\"))\n (ul (map (fn (item) (li :key item item))\n (deref filtered))))))" "lisp"))
(p "React equivalent: " (code "startTransition(() => setFiltered(...))") ". SX uses " (code "schedule-idle") " (" (code "requestIdleCallback") " under the hood) to defer the expensive " (code "filter") " operation, and " (code "batch") " to group the result into one update. Fine-grained signals already avoid the jank that makes transitions critical in React — this pattern is for truly expensive computations.")))
(defcomp ~reactive-islands/demo/example-stores ()
(~docs/page :title "Shared Stores"
(p "React uses " (code "Context") " or state management libraries for cross-component state. SX uses " (code "def-store") " / " (code "use-store") " — named signal containers that persist across island creation/destruction.")
(~reactive-islands/index/demo-store-writer)
(~reactive-islands/index/demo-store-reader)
(~docs/code :code (highlight ";; Island A — creates/writes the store\n(defisland ~reactive-islands/demo/store-writer ()\n (let ((store (def-store \"theme\" (fn ()\n (dict \"color\" (signal \"violet\")\n \"dark\" (signal false))))))\n (select :bind (get store \"color\")\n (option :value \"violet\" \"Violet\")\n (option :value \"blue\" \"Blue\"))\n (input :type \"checkbox\" :bind (get store \"dark\"))))\n\n;; Island B — reads the same store, different island\n(defisland ~reactive-islands/demo/store-reader ()\n (let ((store (use-store \"theme\")))\n (div :class (str \"bg-\" (deref (get store \"color\")) \"-100\")\n \"Styled by signals from Island A\")))" "lisp"))
(p "React equivalent: " (code "createContext") " + " (code "useContext") " or Redux/Zustand. Stores are simpler — just named dicts of signals at page scope. " (code "def-store") " creates once, " (code "use-store") " retrieves. Stores survive island disposal but clear on full page navigation.")))
(defcomp ~reactive-islands/demo/example-event-bridge-demo ()
(~docs/page :title "Event Bridge"
(p "Server-rendered content inside an island (an htmx \"lake\") can communicate with island signals via DOM custom events. Buttons with " (code "data-sx-emit") " dispatch events that island effects catch.")
(~reactive-islands/index/demo-event-bridge)
(~docs/code :code (highlight ";; Island listens for custom events from server-rendered content\n(defisland ~reactive-islands/demo/event-bridge ()\n (let ((messages (signal (list))))\n ;; Bridge: auto-listen for \"inbox:message\" events\n (bridge-event container \"inbox:message\" messages\n (fn (detail) (append (deref messages) (get detail \"text\"))))\n (div\n ;; Lake content (server-rendered) has data-sx-emit buttons\n (div :id \"lake\"\n :sx-get \"/my-content\"\n :sx-swap \"innerHTML\"\n :sx-trigger \"load\")\n ;; Island reads the signal reactively\n (ul (map (fn (msg) (li msg)) (deref messages))))))" "lisp"))
(p "The " (code "data-sx-emit") " attribute is processed by the client engine — it adds a click handler that dispatches a CustomEvent with the JSON from " (code "data-sx-emit-detail") ". The event bubbles up to the island container where " (code "bridge-event") " catches it.")))
(defcomp ~reactive-islands/demo/example-defisland ()
(~docs/page :title "How defisland Works"
(p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with a reactive boundary. Inside an island, " (code "deref") " subscribes DOM nodes to signals.")
(~docs/code :code (highlight ";; Definition — same syntax as defcomp\n(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div\n (span (deref count)) ;; reactive text node\n (button :on-click (fn (e) (swap! count inc)) ;; event handler\n \"+\"))))\n\n;; Usage — same as any component\n(~reactive-islands/demo/counter :initial 42)\n\n;; Server-side rendering:\n;; <div data-sx-island=\"counter\" data-sx-state='{\"initial\":42}'>\n;; <span>42</span><button>+</button>\n;; </div>\n;;\n;; Client hydrates: signals + effects + event handlers attach" "lisp"))
(p "Each " (code "deref") " call registers the enclosing DOM node as a subscriber. Signal changes update " (em "only") " the subscribed nodes — no virtual DOM, no diffing, no component re-renders.")))
(defcomp ~reactive-islands/demo/example-tests ()
(~docs/page :title "Test Suite"
(p "17 tests verify the signal runtime against the spec. All pass in the Python test runner (which uses the hand-written evaluator with native platform primitives).")
(~docs/code :code (highlight ";; Signal basics (6 tests)\n(assert-true (signal? (signal 42)))\n(assert-equal 42 (deref (signal 42)))\n(assert-equal 5 (deref 5)) ;; non-signal passthrough\n\n;; reset! changes value\n(let ((s (signal 0)))\n (reset! s 10)\n (assert-equal 10 (deref s)))\n\n;; reset! does NOT notify when value unchanged (identical? check)\n\n;; Computed (3 tests)\n(let ((a (signal 3)) (b (signal 4))\n (sum (computed (fn () (+ (deref a) (deref b))))))\n (assert-equal 7 (deref sum))\n (reset! a 10)\n (assert-equal 14 (deref sum)))\n\n;; Effects (4 tests) — immediate run, re-run on change, dispose, cleanup\n;; Batch (1 test) — defers notifications, deduplicates subscribers\n;; defisland (3 tests) — creates island, callable, accepts children" "lisp"))
(p :class "mt-2 text-sm text-stone-500" "Run: " (code "python3 shared/sx/tests/run.py signals"))))
(defcomp ~reactive-islands/demo/example-coverage ()
(~docs/page :title "React Feature Coverage"
(p "Every React feature has an SX equivalent — most are simpler because signals are fine-grained.")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "React")
(th :class "px-3 py-2 font-medium text-stone-600" "SX")
(th :class "px-3 py-2 font-medium text-stone-600" "Demo")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useState")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(signal value)")
(td :class "px-3 py-2 text-xs text-stone-500" "#1"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useMemo")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(computed (fn () ...))")
(td :class "px-3 py-2 text-xs text-stone-500" "#1, #2"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useEffect")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(effect (fn () ...))")
(td :class "px-3 py-2 text-xs text-stone-500" "#3"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useRef")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(dict \"current\" nil) + :ref")
(td :class "px-3 py-2 text-xs text-stone-500" "#9"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "useCallback")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(fn (...) ...) — no dep arrays")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "className / style")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":class (str ...) :style (str ...)")
(td :class "px-3 py-2 text-xs text-stone-500" "#10"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Controlled inputs")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":bind signal")
(td :class "px-3 py-2 text-xs text-stone-500" "#6"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "key prop")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":key value")
(td :class "px-3 py-2 text-xs text-stone-500" "#5"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "createPortal")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(portal \"#target\" ...)")
(td :class "px-3 py-2 text-xs text-stone-500" "#7"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "ErrorBoundary")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(error-boundary fallback ...)")
(td :class "px-3 py-2 text-xs text-stone-500" "#8"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Suspense + use()")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(resource fn) + cond/deref")
(td :class "px-3 py-2 text-xs text-stone-500" "#11"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "startTransition")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "schedule-idle + batch")
(td :class "px-3 py-2 text-xs text-stone-500" "#12"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Context / Redux")
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "def-store / use-store")
(td :class "px-3 py-2 text-xs text-stone-500" "#13"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Virtual DOM / diffing")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained signals update exact DOM nodes")
(td :class "px-3 py-2 text-xs text-stone-500" ""))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "JSX / build step")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — s-expressions are the syntax")
(td :class "px-3 py-2 text-xs text-stone-500" ""))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Server Components")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — aser mode already expands server-side")
(td :class "px-3 py-2 text-xs text-stone-500" ""))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Concurrent rendering")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained updates are inherently incremental")
(td :class "px-3 py-2 text-xs text-stone-500" ""))
(tr
(td :class "px-3 py-2 text-stone-700" "Hooks rules")
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — signals are values, no ordering rules")
(td :class "px-3 py-2 text-xs text-stone-500" "")))))))

View File

@@ -28,7 +28,7 @@
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
(li (strong "Swap inside island: ") "Signals survive. The lake content is replaced but the island's signal closures are untouched. Effects re-bind to new DOM nodes if needed.")
(li (strong "Swap outside island: ") "Signals survive. The island is not affected by swaps to other parts of the page.")
(li (strong "Swap replaces island: ") "Signals are " (em "lost") ". The island is disposed. This is where " (a :href "/sx/(geography.(reactive.named-stores))" :sx-get "/sx/(geography.(reactive.named-stores))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "named stores") " come in — they persist at page level, surviving island destruction.")))
(li (strong "Swap replaces island: ") "Signals are " (em "lost") ". The island is disposed. This is where " (a :href "/sx/(geography.(reactive.(named-stores)))" :sx-get "/sx/(geography.(reactive.(named-stores)))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "named stores") " come in — they persist at page level, surviving island destruction.")))
(~docs/section :title "Spec" :id "spec"
(p "The event bridge is spec'd in " (code "signals.sx") " (sections 12-13). Three functions:")

View File

@@ -63,6 +63,41 @@
(li (strong "Updates: ") "Signal changes update only subscribed DOM nodes. No full island re-render")
(li (strong "Disposal: ") "Island removed from DOM — all signals and effects cleaned up via " (code "with-island-scope"))))
(~docs/section :title "htmx Lakes" :id "lakes"
(p "An htmx lake is server-driven content " (em "inside") " a reactive island. The island provides the reactive boundary; the lake content is swapped via " (code "sx-get") "/" (code "sx-post") " like normal hypermedia. This works because signals live in closures, not the DOM.")
(div :class "space-y-2 mt-3"
(div :class "rounded border border-green-200 bg-green-50 p-3"
(div :class "font-semibold text-green-800 text-sm" "Swap inside island")
(p :class "text-sm text-stone-600 mt-1" "Lake content replaced. Signals survive. Effects rebind to new DOM."))
(div :class "rounded border border-green-200 bg-green-50 p-3"
(div :class "font-semibold text-green-800 text-sm" "Swap outside island")
(p :class "text-sm text-stone-600 mt-1" "Different part of page updated. Island completely unaffected."))
(div :class "rounded border border-amber-200 bg-amber-50 p-3"
(div :class "font-semibold text-amber-800 text-sm" "Swap replaces island")
(p :class "text-sm text-stone-600 mt-1" "Island disposed. Local signals lost. Named stores persist — new island reconnects via " (code "use-store") "."))
(div :class "rounded border border-stone-200 p-3"
(div :class "font-semibold text-stone-800 text-sm" "Full page navigation")
(p :class "text-sm text-stone-600 mt-1" "Everything cleared. " (code "clear-stores") " wipes the registry."))))
(~docs/section :title "Event Bridge" :id "event-bridge"
(p "A lake has no access to island signals, but can communicate back via DOM custom events. Elements with " (code "data-sx-emit") " dispatch a " (code "CustomEvent") " on click; an island effect catches it and updates a signal.")
(~docs/code :code (highlight ";; Island listens for events from server-rendered lake content\n(bridge-event container \"cart:add\" items\n (fn (detail) (append (deref items) detail)))\n\n;; Server-rendered button dispatches CustomEvent on click\n(button :data-sx-emit \"cart:add\"\n :data-sx-emit-detail (json-serialize (dict :id 42))\n \"Add to Cart\")" "lisp"))
(p "Three primitives: " (code "emit-event") " (dispatch), " (code "on-event") " (listen), " (code "bridge-event") " (listen + update signal with automatic cleanup)."))
(~docs/section :title "Named Stores" :id "stores"
(p "A named store is a dict of signals at " (em "page") " scope — not island scope. Multiple islands share the same signals. Stores survive island destruction and recreation.")
(~docs/code :code (highlight ";; Create once — idempotent, returns existing on second call\n(def-store \"cart\" (fn ()\n (dict :items (signal (list))\n :count (computed (fn () (length (deref items)))))))\n\n;; Use from any island, anywhere in the DOM\n(let ((store (use-store \"cart\")))\n (span (deref (get store \"count\"))))" "lisp"))
(p (code "def-store") " creates, " (code "use-store") " retrieves, " (code "clear-stores") " wipes all on full page navigation."))
(~docs/section :title "Design Principles" :id "principles"
(ol :class "space-y-2 text-stone-600 list-decimal list-inside"
(li (strong "Islands are opt-in.") " " (code "defcomp") " is the default. " (code "defisland") " adds reactivity. No overhead for static content.")
(li (strong "Signals are values, not hooks.") " Create anywhere — conditionals, loops, closures. No rules of hooks, no dependency arrays.")
(li (strong "Fine-grained, not component-grained.") " A signal change updates the specific DOM node that reads it. No virtual DOM, no diffing, no component re-renders.")
(li (strong "The server is still the authority.") " Islands handle client interactions. The server handles auth, data, routing.")
(li (strong "Spec-first.") " Signal semantics live in " (code "signals.sx") ". Bootstrapped to JS and Python. Same primitives on future hosts.")
(li (strong "No build step.") " Reactive bindings created at runtime. No JSX compilation, no bundler plugins.")))
(~docs/section :title "Implementation Status" :id "status"
(p :class "text-stone-600 mb-3" "All signal logic lives in " (code ".sx") " spec files and is bootstrapped to JavaScript and Python. No SX-specific logic in host languages.")
@@ -141,11 +176,6 @@
(td :class "px-3 py-2 text-stone-700" "Portals")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: portal render-dom form"))
(tr
(td :class "px-3 py-2 text-stone-700" "Phase 2 remaining")
(td :class "px-3 py-2 text-stone-500 font-medium" "P2")
(td :class "px-3 py-2 font-mono text-xs text-stone-500"
(a :href "/sx/(geography.(reactive.phase2))" :sx-get "/sx/(geography.(reactive.phase2))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Error boundaries + resource + patterns")))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Error boundaries")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
@@ -430,18 +460,18 @@
;; Set initial filtered list
(reset! filtered all-items)
;; Filter effect — defers via schedule-idle so typing stays snappy
(effect (fn ()
(let ((q (lower (deref query))))
(if (= q "")
(do (reset! pending false)
(reset! filtered all-items))
(do (reset! pending true)
(schedule-idle (fn ()
(batch (fn ()
(reset! filtered
(filter (fn (item) (contains? (lower item) q)) all-items))
(reset! pending false))))))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
(let ((_eff (effect (fn ()
(let ((q (lower (deref query))))
(if (= q "")
(do (reset! pending false)
(reset! filtered all-items))
(do (reset! pending true)
(schedule-idle (fn ()
(batch (fn ()
(reset! filtered
(filter (fn (item) (contains? (lower item) q)) all-items))
(reset! pending false))))))))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
(div :class "flex items-center gap-3"
(input :type "text" :bind query :placeholder "Filter features..."
:class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48")
@@ -451,7 +481,7 @@
(map (fn (item)
(li :key item :class "text-sm text-stone-700 bg-white rounded px-3 py-1.5"
item))
(deref filtered))))))
(deref filtered)))))))
;; 13. Shared stores — cross-island state via def-store / use-store
(defisland ~reactive-islands/index/demo-store-writer ()
@@ -485,16 +515,16 @@
;; 14. Event bridge — lake→island communication via custom DOM events
(defisland ~reactive-islands/index/demo-event-bridge ()
(let ((messages (signal (list)))
(container nil))
;; Bridge: listen for "inbox:message" events from server-rendered content
(effect (fn ()
(when container
(on-event container "inbox:message"
(fn (e)
(swap! messages (fn (old)
(append old (get (event-detail e) "text")))))))))
(div :ref (dict "current" nil)
(let ((container-ref (dict "current" nil))
(messages (signal (list)))
(_eff (schedule-idle (fn ()
(let ((el (get container-ref "current")))
(when el
(on-event el "inbox:message"
(fn (e)
(swap! messages (fn (old)
(append old (get (event-detail e) "text"))))))))))))
(div :ref container-ref
(p :class "text-xs font-semibold text-stone-500 mb-2" "Event Bridge Demo")
(p :class "text-sm text-stone-600 mb-2"
"The buttons below simulate server-rendered content dispatching events into the island.")

View File

@@ -274,98 +274,90 @@
(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."))
;; =====================================================================
;; X. Live demos
;; =====================================================================
(~docs/section :title "Examples" :id "examples"
(p (strong "Live interactive islands") " — click the buttons, inspect the DOM.")
(ol :class "space-y-1"
(map (fn (item)
(li (a :href (get item "href")
:sx-get (get item "href") :sx-target "#main-panel"
:sx-select "#main-panel" :sx-swap "outerHTML"
:sx-push-url "true"
:class "text-violet-600 hover:underline"
(get item "label"))))
marshes-examples-nav-items)))))
(~docs/section :title "Live demos" :id "demos"
(p (strong "These are live interactive islands") " — not static code snippets. Click the buttons. Inspect the DOM.")
;; ---------------------------------------------------------------------------
;; Individual example pages
;; ---------------------------------------------------------------------------
;; -----------------------------------------------------------------
;; Demo 1: Server content feeds reactive state
;; -----------------------------------------------------------------
(defcomp ~reactive-islands/marshes/example-hypermedia-feeds ()
(~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.")
(~docs/subsection :title "Demo 1: 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)
(~reactive-islands/marshes/demo-marsh-product)
(~docs/code :code (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 :code (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 :code (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"))
(~docs/code :code (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.")))
(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."))
(defcomp ~reactive-islands/marshes/example-server-signals ()
(~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.")
;; -----------------------------------------------------------------
;; Demo 2: Server → Signal (simulated + live)
;; -----------------------------------------------------------------
(div :class "space-y-3"
(~reactive-islands/marshes/demo-marsh-store-writer)
(~reactive-islands/marshes/demo-marsh-store-reader))
(~docs/subsection :title "Demo 2: 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.")
(p :class "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 :class "space-y-3"
(~reactive-islands/marshes/demo-marsh-store-writer)
(~reactive-islands/marshes/demo-marsh-store-reader))
(div :class "mt-4 rounded border border-stone-200 bg-stone-50 p-3"
(p :class "text-sm font-medium text-stone-700 mb-2" "Server endpoint (ready for morph integration):")
(div :id "marsh-flash-target"
:class "min-h-[2rem]")
(button :class "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"))
(p :class "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.")
(~docs/code :code (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"))
(div :class "mt-4 rounded border border-stone-200 bg-stone-50 p-3"
(p :class "text-sm font-medium text-stone-700 mb-2" "Server endpoint (ready for morph integration):")
(div :id "marsh-flash-target"
:class "min-h-[2rem]")
(button :class "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"))
(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.")))
(~docs/code :code (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"))
(defcomp ~reactive-islands/marshes/example-on-settle ()
(~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.")
(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."))
(~reactive-islands/marshes/demo-marsh-settle)
;; -----------------------------------------------------------------
;; Demo 3: sx-on-settle — post-swap SX evaluation
;; -----------------------------------------------------------------
(~docs/code :code (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"))
(~docs/subsection :title "Demo 3: 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.")
(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.")))
(~reactive-islands/marshes/demo-marsh-settle)
(defcomp ~reactive-islands/marshes/example-signal-triggers ()
(~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.")
(~docs/code :code (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"))
(~reactive-islands/marshes/demo-marsh-signal-url)
(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."))
(~docs/code :code (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"))
;; -----------------------------------------------------------------
;; Demo 4: Signal-bound triggers
;; -----------------------------------------------------------------
(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.")))
(~docs/subsection :title "Demo 4: 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.")
(defcomp ~reactive-islands/marshes/example-view-transform ()
(~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-signal-url)
(~reactive-islands/marshes/demo-marsh-view-transform)
(~docs/code :code (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"))
(~docs/code :code (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 "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."))
;; -----------------------------------------------------------------
;; Demo 5: Reactive view transform
;; -----------------------------------------------------------------
(~docs/subsection :title "Demo 5: 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 :code (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."))
)))
(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

@@ -46,7 +46,7 @@
(~docs/section :title "htmx Lakes" :id "lakes"
(p "An htmx lake is server-driven content " (em "inside") " a reactive island. The island provides the reactive boundary; the lake content is swapped via " (code "sx-get") "/" (code "sx-post") " like normal hypermedia.")
(p "This works because signals live in JavaScript closures, not in the DOM. When a swap replaces lake content, the island's signals are unaffected. The lake can communicate back to the island via the " (a :href "/sx/(geography.(reactive.event-bridge))" :sx-get "/sx/(geography.(reactive.event-bridge))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "event bridge") ".")
(p "This works because signals live in JavaScript closures, not in the DOM. When a swap replaces lake content, the island's signals are unaffected. The lake can communicate back to the island via the " (a :href "/sx/(geography.(reactive.(event-bridge)))" :sx-get "/sx/(geography.(reactive.(event-bridge)))" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "event bridge") ".")
(~docs/subsection :title "Navigation scenarios"
(div :class "space-y-3"

View File

@@ -2,7 +2,7 @@
;; Spec Explorer — structured interactive view of SX spec files
;; ---------------------------------------------------------------------------
(defcomp ~specs-explorer/spec-explorer-content (&key data)
(defcomp ~specs-explorer/spec-explorer-content (&key data) :affinity :server
(~docs/page :title (str (get data "title") " — Explorer")
;; Header with filename and source link

View File

@@ -133,7 +133,7 @@
;; Geography + Applications + Etc
(div
(p :class "font-semibold text-stone-700 text-sm mt-2 mb-1" "Geography")
(p (a :href "/sx/(geography.(reactive.demo))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(reactive.demo))"))
(p (a :href "/sx/(geography.(reactive.(examples.counter)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(reactive.(examples.counter)))"))
(p (a :href "/sx/(geography.(hypermedia.(reference.attributes)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(hypermedia.(reference.attributes)))"))
(p (a :href "/sx/(geography.(hypermedia.(example.click-to-load)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(hypermedia.(example.click-to-load)))"))
(p (a :href "/sx/(geography.(hypermedia.(example.infinite-scroll)))" :class "font-mono text-violet-600 hover:underline text-xs" "/(geography.(hypermedia.(example.infinite-scroll)))"))
@@ -314,7 +314,7 @@
(p "Each additional dot pops one more level of nesting. "
"N dots = pop N-1 levels:")
(~docs/code :code (highlight
";; Current: /(geography.(hypermedia.(example.progress-bar)))\n;;\n;; ... → /(geography.(hypermedia)) ;; pop 2 levels\n;; .... → /(geography) ;; pop 3 levels\n;; ..... → / ;; pop 4 levels (root)\n;;\n;; Combine with a slug to navigate across sections:\n;; ...reactive.demo → /(geography.(reactive.demo)) ;; pop 2, into reactive\n;; ....language.(doc.intro)\n;; → /(language.(doc.intro)) ;; pop 3, into language"
";; Current: /(geography.(hypermedia.(example.progress-bar)))\n;;\n;; ... → /(geography.(hypermedia)) ;; pop 2 levels\n;; .... → /(geography) ;; pop 3 levels\n;; ..... → / ;; pop 4 levels (root)\n;;\n;; Combine with a slug to navigate across sections:\n;; ...reactive.(examples) → /(geography.(reactive.(examples))) ;; pop 2, into reactive\n;; ....language.(doc.intro)\n;; → /(language.(doc.intro)) ;; pop 3, into language"
"lisp")))
(~docs/subsection :title "Why this is functional, not textual"

View File

@@ -1,7 +1,7 @@
;; SX docs — documentation page components
(defcomp ~docs/page (&key title &rest children)
(div :class "max-w-4xl mx-auto px-6 py-8"
(div :class "max-w-4xl mx-auto px-6 pb-8 pt-4"
(div :class "prose prose-stone max-w-none space-y-6" children)))
(defcomp ~docs/section (&key title id &rest children)

View File

@@ -7,7 +7,7 @@
;; ---------------------------------------------------------------------------
(defhandler ref-time (&key)
(let ((now (format-time (now) "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(span :class "text-stone-800 text-sm"
"Server time: " (strong now))))
@@ -49,7 +49,7 @@
;; ---------------------------------------------------------------------------
(defhandler ref-trigger-search (&key)
(let ((q (or (request-arg "q") "")))
(let ((q (helper "request-arg" "q" "")))
(if (empty? q)
(span :class "text-stone-400 text-sm" "Start typing to trigger a search.")
(span :class "text-stone-800 text-sm"
@@ -60,7 +60,7 @@
;; ---------------------------------------------------------------------------
(defhandler ref-swap-item (&key)
(let ((now (format-time (now) "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(div :class "text-sm text-violet-700"
"New item (" now ")")))
@@ -69,7 +69,7 @@
;; ---------------------------------------------------------------------------
(defhandler ref-oob (&key)
(let ((now (format-time (now) "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(span :class "text-emerald-700 text-sm"
"Main updated at " now)
@@ -82,7 +82,7 @@
;; ---------------------------------------------------------------------------
(defhandler ref-select-page (&key)
(let ((now (format-time (now) "%H:%M:%S")))
(let ((now (helper "now" "%H:%M:%S")))
(<>
(div :id "the-header"
(h3 "Page header — not selected"))
@@ -98,7 +98,7 @@
(defhandler ref-slow-echo (&key)
(sleep 800)
(let ((q (or (request-arg "q") "")))
(let ((q (helper "request-arg" "q" "")))
(span :class "text-stone-800 text-sm"
"Echo: " (strong q))))
@@ -116,7 +116,7 @@
;; ---------------------------------------------------------------------------
(defhandler ref-echo-headers (&key)
(let ((headers (request-headers :prefix "X-")))
(let ((headers (helper "request-headers" :prefix "X-")))
(if (empty? headers)
(span :class "text-stone-400 text-sm" "No custom headers received.")
(ul :class "text-sm text-stone-700 space-y-1"
@@ -129,7 +129,7 @@
;; ---------------------------------------------------------------------------
(defhandler ref-echo-vals (&key)
(let ((vals (request-args)))
(let ((vals (helper "request-args")))
(if (empty? vals)
(span :class "text-stone-400 text-sm" "No values received.")
(ul :class "text-sm text-stone-700 space-y-1"

View File

@@ -29,5 +29,8 @@ def _load_sx_page_files() -> None:
helpers = get_page_helpers("sx")
for name, fn in helpers.items():
PRIMITIVES[name] = fn
# helper is registered as an IO primitive in primitives_io.py,
# intercepted by async_eval before hitting the CEK machine.
import logging; logging.getLogger("sx.pages").info("Injected %d page helpers as primitives: %s", len(helpers), list(helpers.keys())[:5])
load_page_dir(os.path.dirname(__file__), "sx")

View File

@@ -369,17 +369,11 @@
:path "/language/specs/explore/<slug>"
:auth :public
:layout :sx-docs
:data (helper "spec-explorer-data-by-slug" slug)
:content (~layouts/doc :path (str "/sx/(language.(spec.(explore." slug ")))")
(let ((spec (find-spec slug)))
(if spec
(let ((data (spec-explorer-data
(get spec "filename")
(get spec "title")
(get spec "desc"))))
(if data
(~specs-explorer/spec-explorer-content :data data)
(~specs/not-found :slug slug)))
(~specs/not-found :slug slug)))))
(if data
(~specs-explorer/spec-explorer-content :data data)
(~specs/not-found :slug slug))))
;; ---------------------------------------------------------------------------
;; Bootstrappers section

View File

@@ -35,6 +35,7 @@ def _register_sx_helpers() -> None:
"prove-data": _prove_data,
"page-helpers-demo-data": _page_helpers_demo_data,
"spec-explorer-data": _spec_explorer_data,
"spec-explorer-data-by-slug": _spec_explorer_data_by_slug,
"handler-source": _handler_source,
})
@@ -329,6 +330,35 @@ def _collect_symbols(expr) -> set[str]:
return result
_SPEC_SLUG_MAP = {
"parser": ("parser.sx", "Parser", "Tokenization and parsing"),
"evaluator": ("eval.sx", "Evaluator", "Tree-walking evaluation"),
"primitives": ("primitives.sx", "Primitives", "Built-in pure functions"),
"render": ("render.sx", "Renderer", "Three rendering modes"),
"special-forms": ("special-forms.sx", "Special Forms", "Special form dispatch"),
"signals": ("signals.sx", "Signals", "Fine-grained reactive primitives"),
"adapter-dom": ("adapter-dom.sx", "DOM Adapter", "Client-side DOM rendering"),
"adapter-html": ("adapter-html.sx", "HTML Adapter", "Server-side HTML rendering"),
"adapter-sx": ("adapter-sx.sx", "SX Adapter", "SX wire format serialization"),
"engine": ("engine.sx", "SxEngine", "Pure logic for the browser engine"),
"orchestration": ("orchestration.sx", "Orchestration", "Browser lifecycle"),
"boot": ("boot.sx", "Boot", "Browser initialization"),
"router": ("router.sx", "Router", "URL parsing and route matching"),
"boundary": ("boundary.sx", "Boundary", "Language/platform boundary"),
"continuations": ("continuations.sx", "Continuations", "Delimited continuations"),
"types": ("types.sx", "Types", "Optional gradual type system"),
}
def _spec_explorer_data_by_slug(slug: str) -> dict | None:
"""Look up spec by slug and return explorer data."""
entry = _SPEC_SLUG_MAP.get(slug)
if not entry:
return None
filename, title, desc = entry
return _spec_explorer_data(filename, title, desc)
def _spec_explorer_data(filename: str, title: str = "", desc: str = "") -> dict | None:
"""Parse a spec file into structured metadata for the spec explorer.

View File

@@ -150,16 +150,40 @@ async def eval_sx_url(raw_path: str) -> Any:
page_ast = expr
else:
import os
if os.environ.get("SX_USE_REF") == "1":
from shared.sx.ref.async_eval_ref import async_eval
else:
from shared.sx.async_eval import async_eval
use_ocaml = os.environ.get("SX_USE_OCAML") == "1"
try:
page_ast = await async_eval(expr, env, ctx)
except Exception as e:
logger.error("SX URL page-fn eval failed for %s: %s", raw_path, e, exc_info=True)
return None
if use_ocaml:
# OCaml kernel — the universal evaluator
try:
from shared.sx.ocaml_bridge import get_bridge
from shared.sx.parser import serialize, parse_all
bridge = await get_bridge()
sx_text = serialize(expr)
ocaml_ctx = {"_helper_service": "sx"}
result_text = await bridge.eval(sx_text, ctx=ocaml_ctx)
if result_text:
parsed = parse_all(result_text)
page_ast = parsed[0] if parsed else []
else:
page_ast = []
except Exception as e:
logger.error("SX URL page-fn eval (OCaml) failed for %s: %s",
raw_path, e, exc_info=True)
return None
else:
# Python fallback
if os.environ.get("SX_USE_REF") == "1":
from shared.sx.ref.async_eval_ref import async_eval
else:
from shared.sx.async_eval import async_eval
try:
page_ast = await async_eval(expr, env, ctx)
except Exception as e:
logger.error("SX URL page-fn eval failed for %s: %s",
raw_path, e, exc_info=True)
return None
if page_ast is None:
page_ast = []

12
sx/tests/conftest.py Normal file
View File

@@ -0,0 +1,12 @@
"""Pytest configuration for Playwright tests."""
import pytest
@pytest.fixture(scope="session")
def browser_type_launch_args():
return {"headless": True}
@pytest.fixture(scope="session")
def browser_context_args():
return {"ignore_https_errors": True}

543
sx/tests/test_demos.py Normal file
View File

@@ -0,0 +1,543 @@
"""Automated Playwright tests for SX docs demos.
Covers hypermedia examples, reactive islands, and marshes.
Run against the dev server:
cd sx/tests
pytest test_demos.py -v --headed # visible browser
pytest test_demos.py -v # headless
Requires: pip install pytest-playwright
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
import os
BASE = os.environ.get("SX_TEST_BASE", "https://sx.rose-ash.com")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def browser_context_args():
return {"ignore_https_errors": True}
def nav(page: Page, path: str):
"""Navigate to an SX URL and wait for rendered content."""
page.goto(f"{BASE}/sx/{path}", wait_until="networkidle")
# Wait for SX to render — look for any heading or paragraph in main panel
page.wait_for_selector("#main-panel h2, #main-panel p, #main-panel div", timeout=15000)
# ---------------------------------------------------------------------------
# Hypermedia Examples
# ---------------------------------------------------------------------------
class TestClickToLoad:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.click-to-load)))")
expect(page.locator("#main-panel")).to_contain_text("Click to Load")
def test_click_loads_content(self, page: Page):
nav(page, "(geography.(hypermedia.(example.click-to-load)))")
page.click("button:has-text('Load content')")
expect(page.locator("#click-result")).to_contain_text(
"Content loaded", timeout=5000
)
class TestFormSubmission:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.form-submission)))")
expect(page.locator("#main-panel")).to_contain_text("Form Submission")
def test_submit_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.form-submission)))")
page.fill("input[name='name']", "TestUser")
page.click("button[type='submit']")
expect(page.locator("#form-result")).to_contain_text("TestUser", timeout=5000)
class TestPolling:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.polling)))")
expect(page.locator("#main-panel")).to_contain_text("Polling")
def test_poll_updates(self, page: Page):
nav(page, "(geography.(hypermedia.(example.polling)))")
expect(page.locator("#poll-target")).to_contain_text("Server time", timeout=10000)
class TestDeleteRow:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.delete-row)))")
expect(page.locator("#main-panel")).to_contain_text("Delete Row")
class TestInlineEdit:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-edit)))")
expect(page.locator("#main-panel")).to_contain_text("Inline Edit")
def test_edit_shows_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-edit)))")
page.click("#edit-target button:has-text('edit')")
expect(page.locator("input[name='value']")).to_be_visible(timeout=5000)
class TestOobSwaps:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.oob-swaps)))")
expect(page.locator("#main-panel")).to_contain_text("OOB")
def test_oob_updates_both_boxes(self, page: Page):
nav(page, "(geography.(hypermedia.(example.oob-swaps)))")
page.click("button:has-text('Update both boxes')")
expect(page.locator("#oob-box-a")).to_contain_text("Box A updated", timeout=5000)
expect(page.locator("#oob-box-b").last).to_contain_text("Box B updated", timeout=5000)
class TestLazyLoading:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.lazy-loading)))")
expect(page.locator("#main-panel")).to_contain_text("Lazy Loading")
def test_content_loads_automatically(self, page: Page):
nav(page, "(geography.(hypermedia.(example.lazy-loading)))")
expect(page.locator("#lazy-target")).to_contain_text("Content loaded", timeout=10000)
class TestInfiniteScroll:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.infinite-scroll)))")
expect(page.locator("#main-panel")).to_contain_text("Infinite Scroll")
class TestProgressBar:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.progress-bar)))")
expect(page.locator("#main-panel")).to_contain_text("Progress Bar")
def test_progress_starts(self, page: Page):
nav(page, "(geography.(hypermedia.(example.progress-bar)))")
page.click("button:has-text('Start job')")
expect(page.locator("#progress-target")).to_contain_text("complete", timeout=10000)
class TestActiveSearch:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.active-search)))")
expect(page.locator("#main-panel")).to_contain_text("Active Search")
def test_search_returns_results(self, page: Page):
nav(page, "(geography.(hypermedia.(example.active-search)))")
page.locator("input[name='q']").type("Py")
expect(page.locator("#search-results")).to_contain_text("Python", timeout=5000)
class TestInlineValidation:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-validation)))")
expect(page.locator("#main-panel")).to_contain_text("Inline Validation")
def test_invalid_email(self, page: Page):
nav(page, "(geography.(hypermedia.(example.inline-validation)))")
page.fill("input[name='email']", "notanemail")
page.locator("input[name='email']").blur()
expect(page.locator("#email-feedback")).to_contain_text("Invalid", timeout=5000)
class TestValueSelect:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.value-select)))")
expect(page.locator("#main-panel")).to_contain_text("Value Select")
def test_select_changes_options(self, page: Page):
nav(page, "(geography.(hypermedia.(example.value-select)))")
page.select_option("select[name='category']", "Languages")
expect(page.locator("#value-items")).to_contain_text("Python", timeout=5000)
class TestResetOnSubmit:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.reset-on-submit)))")
expect(page.locator("#main-panel")).to_contain_text("Reset")
def test_has_form(self, page: Page):
nav(page, "(geography.(hypermedia.(example.reset-on-submit)))")
expect(page.locator("form")).to_be_visible(timeout=5000)
class TestEditRow:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.edit-row)))")
expect(page.locator("#main-panel")).to_contain_text("Edit Row")
class TestBulkUpdate:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.bulk-update)))")
expect(page.locator("#main-panel")).to_contain_text("Bulk Update")
def test_has_checkboxes(self, page: Page):
nav(page, "(geography.(hypermedia.(example.bulk-update)))")
expect(page.locator("input[type='checkbox']").first).to_be_visible(timeout=5000)
class TestSwapPositions:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.swap-positions)))")
expect(page.locator("#main-panel")).to_contain_text("Swap")
class TestSelectFilter:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.select-filter)))")
expect(page.locator("#main-panel")).to_contain_text("Select Filter")
class TestTabs:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.tabs)))")
expect(page.locator("#main-panel")).to_contain_text("Tabs")
class TestAnimations:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.animations)))")
expect(page.locator("#main-panel")).to_contain_text("Animation")
class TestDialogs:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.dialogs)))")
expect(page.locator("#main-panel")).to_contain_text("Dialog")
class TestKeyboardShortcuts:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.keyboard-shortcuts)))")
expect(page.locator("#main-panel")).to_contain_text("Keyboard")
class TestPutPatch:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.put-patch)))")
expect(page.locator("#main-panel")).to_contain_text("PUT")
class TestJsonEncoding:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.json-encoding)))")
expect(page.locator("#main-panel")).to_contain_text("JSON")
class TestValsAndHeaders:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.vals-and-headers)))")
expect(page.locator("#main-panel")).to_contain_text("Vals")
class TestLoadingStates:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.loading-states)))")
expect(page.locator("#main-panel")).to_contain_text("Loading")
class TestSyncReplace:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.sync-replace)))")
expect(page.locator("#main-panel")).to_contain_text("Request Abort")
class TestRetry:
def test_page_loads(self, page: Page):
nav(page, "(geography.(hypermedia.(example.retry)))")
expect(page.locator("#main-panel")).to_contain_text("Retry")
# ---------------------------------------------------------------------------
# Reactive Islands
# ---------------------------------------------------------------------------
class TestReactiveIslandsOverview:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive))")
expect(page.locator("#main-panel")).to_contain_text("Reactive Islands")
def test_architecture_table(self, page: Page):
nav(page, "(geography.(reactive))")
expect(page.locator("table").first).to_be_visible()
class TestReactiveExamplesOverview:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples)))")
expect(page.locator("#main-panel")).to_contain_text("Examples")
class TestReactiveCounter:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.counter)))")
expect(page.locator("#main-panel")).to_contain_text("signal holds a value")
def test_counter_increments(self, page: Page):
nav(page, "(geography.(reactive.(examples.counter)))")
# Find the island's + button
island = page.locator("[data-sx-island*='demo-counter']")
expect(island).to_be_visible(timeout=5000)
# Wait for hydration
page.wait_for_timeout(1000)
initial = island.locator("span.text-2xl").text_content()
island.locator("button", has_text="+").click()
page.wait_for_timeout(500)
updated = island.locator("span.text-2xl").text_content()
assert initial != updated, f"Counter should change: {initial} -> {updated}"
class TestReactiveTemperature:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.temperature)))")
expect(page.locator("#main-panel")).to_contain_text("Temperature")
def test_temperature_updates(self, page: Page):
nav(page, "(geography.(reactive.(examples.temperature)))")
island = page.locator("[data-sx-island*='demo-temperature']")
expect(island).to_be_visible(timeout=5000)
page.wait_for_timeout(1000)
island.locator("button", has_text="+5").click()
page.wait_for_timeout(500)
# Should show celsius value > 20 (default)
expect(island).to_contain_text("25")
class TestReactiveStopwatch:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.stopwatch)))")
expect(page.locator("#main-panel")).to_contain_text("Stopwatch")
class TestReactiveImperative:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.imperative)))")
expect(page.locator("#main-panel")).to_contain_text("Imperative")
class TestReactiveList:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.reactive-list)))")
expect(page.locator("#main-panel")).to_contain_text("Reactive List")
def test_add_item(self, page: Page):
nav(page, "(geography.(reactive.(examples.reactive-list)))")
island = page.locator("[data-sx-island*='demo-reactive-list']")
expect(island).to_be_visible(timeout=5000)
page.wait_for_timeout(1000)
island.locator("button", has_text="Add Item").click()
page.wait_for_timeout(500)
expect(island).to_contain_text("Item 1")
class TestReactiveInputBinding:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.input-binding)))")
expect(page.locator("#main-panel")).to_contain_text("Input Binding")
def test_input_binding(self, page: Page):
nav(page, "(geography.(reactive.(examples.input-binding)))")
island = page.locator("[data-sx-island*='demo-input-binding']")
expect(island).to_be_visible(timeout=5000)
page.wait_for_timeout(1000)
island.locator("input[type='text']").fill("World")
page.wait_for_timeout(500)
expect(island).to_contain_text("Hello, World!")
class TestReactivePortal:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.portal)))")
expect(page.locator("#main-panel")).to_contain_text("Portal")
class TestReactiveErrorBoundary:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.error-boundary)))")
expect(page.locator("#main-panel")).to_contain_text("Error")
class TestReactiveRefs:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.refs)))")
expect(page.locator("#main-panel")).to_contain_text("Refs")
class TestReactiveDynamicClass:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.dynamic-class)))")
expect(page.locator("#main-panel")).to_contain_text("Dynamic")
class TestReactiveResource:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.resource)))")
expect(page.locator("#main-panel")).to_contain_text("Resource")
class TestReactiveTransition:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.transition)))")
expect(page.locator("#main-panel")).to_contain_text("Transition")
class TestReactiveStores:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.stores)))")
expect(page.locator("#main-panel")).to_contain_text("Store")
class TestReactiveEventBridge:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.event-bridge-demo)))")
expect(page.locator("#main-panel")).to_contain_text("Event Bridge")
class TestReactiveDefisland:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.defisland)))")
expect(page.locator("#main-panel")).to_contain_text("defisland")
class TestReactiveTests:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.tests)))")
expect(page.locator("#main-panel")).to_contain_text("test")
class TestReactiveCoverage:
def test_page_loads(self, page: Page):
nav(page, "(geography.(reactive.(examples.coverage)))")
expect(page.locator("#main-panel")).to_contain_text("React")
# ---------------------------------------------------------------------------
# Marshes
# ---------------------------------------------------------------------------
class TestMarshesOverview:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes))")
expect(page.locator("#main-panel")).to_contain_text("Marshes")
class TestMarshesHypermediaFeeds:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.hypermedia-feeds))")
expect(page.locator("#main-panel")).to_contain_text("Hypermedia Feeds")
class TestMarshesServerSignals:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.server-signals))")
expect(page.locator("#main-panel")).to_contain_text("Server Writes")
class TestMarshesOnSettle:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.on-settle))")
expect(page.locator("#main-panel")).to_contain_text("sx-on-settle")
class TestMarshesSignalTriggers:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.signal-triggers))")
expect(page.locator("#main-panel")).to_contain_text("Signal-Bound")
class TestMarshesViewTransform:
def test_page_loads(self, page: Page):
nav(page, "(geography.(marshes.view-transform))")
expect(page.locator("#main-panel")).to_contain_text("Reactive View")
# ---------------------------------------------------------------------------
# Spec Explorer
# ---------------------------------------------------------------------------
class TestSpecExplorer:
def test_evaluator_server_renders(self, page: Page):
"""Server returns spec explorer content with evaluator data."""
resp = page.request.get(f"{BASE}/sx/(language.(spec.(explore.evaluator)))")
assert resp.ok, f"Server returned {resp.status}"
body = resp.text()
assert "Evaluator" in body, "Should contain evaluator title"
assert "eval" in body.lower(), "Should contain evaluator content"
def test_parser_server_renders(self, page: Page):
"""Server returns spec explorer content with parser data."""
resp = page.request.get(f"{BASE}/sx/(language.(spec.(explore.parser)))")
assert resp.ok, f"Server returned {resp.status}"
body = resp.text()
assert "Parser" in body, "Should contain parser title"
def test_has_spec_source(self, page: Page):
"""Spec explorer includes actual spec source code."""
resp = page.request.get(f"{BASE}/sx/(language.(spec.(explore.evaluator)))")
body = resp.text()
assert "define" in body, "Should contain define forms from spec"
assert "eval-expr" in body, "Should contain eval-expr from evaluator spec"
# ---------------------------------------------------------------------------
# Key doc pages (smoke tests)
# ---------------------------------------------------------------------------
class TestDocPages:
@pytest.mark.parametrize("path,expected", [
("(geography.(reactive))", "Reactive Islands"),
("(geography.(hypermedia.(reference.attributes)))", "Attributes"),
("(geography.(scopes))", "Scopes"),
("(geography.(provide))", "Provide"),
("(geography.(spreads))", "Spreads"),
("(language.(doc.introduction))", "Introduction"),
("(language.(spec.core))", "Core"),
("(applications.(cssx))", "CSSX"),
("(etc.(essay.why-sexps))", "Why S-Expressions"),
])
def test_page_loads(self, page: Page, path: str, expected: str):
nav(page, path)
expect(page.locator("#main-panel")).to_contain_text(expected, timeout=10000)
# ---------------------------------------------------------------------------
# Navigation tests
# ---------------------------------------------------------------------------
class TestClientNavigation:
def test_navigate_between_reactive_examples(self, page: Page):
"""Navigate from counter to temperature via server fetch."""
nav(page, "(geography.(reactive.(examples.counter)))")
expect(page.locator("#main-panel")).to_contain_text("signal holds a value", timeout=10000)
# Click temperature link in sibling nav — has sx-get for server fetch
temp_link = page.locator("a[sx-push-url]:has-text('Temperature')").first
expect(temp_link).to_be_visible(timeout=5000)
temp_link.click()
page.wait_for_timeout(5000)
expect(page.locator("#main-panel")).to_contain_text("Temperature", timeout=10000)
def test_reactive_island_works_after_navigation(self, page: Page):
"""Island should be functional after client-side navigation."""
nav(page, "(geography.(reactive.(examples.temperature)))")
page.wait_for_timeout(3000) # Wait for hydration
# Click +5 and verify
island = page.locator("[data-sx-island*='demo-temperature']")
expect(island).to_be_visible(timeout=5000)
island.locator("button", has_text="+5").click()
page.wait_for_timeout(500)
expect(island).to_contain_text("25")