Files
rose-ash/sx/sx/geography/reactive/index.sx
giles 4f02f82f4e HS parser: fix number+comparison keyword collision, eval-hs uses hs-compile
Parser: skip unit suffix when next ident is a comparison keyword
(starts, ends, contains, matches, is, does, in, precedes, follows).
Fixes "123 starts with '12'" returning "123starts" instead of true.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:29:01 +00:00

589 lines
25 KiB
Plaintext

(defcomp
()
(~docs/page
:title "Reactive Islands"
(~docs/section
:title "Architecture"
:id "architecture"
(p "Two orthogonal bars control how an SX page works:")
(ul
(~tw :tokens "space-y-1 text-stone-600 list-disc pl-5")
(li
(strong "Render boundary")
" — where rendering happens (server HTML vs client DOM)")
(li
(strong "State flow")
" — how state flows (server state vs client signals)"))
(div
(~tw :tokens "overflow-x-auto mt-4 mb-4")
(table
(~tw :tokens "w-full text-sm text-left")
(thead
(tr
(~tw :tokens "border-b border-stone-200")
(th (~tw :tokens "py-2 px-3 font-semibold text-stone-700") "")
(th
(~tw :tokens "py-2 px-3 font-semibold text-stone-700")
"Server State")
(th
(~tw :tokens "py-2 px-3 font-semibold text-stone-700")
"Client State")))
(tbody
(~tw :tokens "text-stone-600")
(tr
(~tw :tokens "border-b border-stone-100")
(td
(~tw :tokens "py-2 px-3 font-semibold text-stone-700")
"Server Rendering")
(td (~tw :tokens "py-2 px-3") "Pure hypermedia (htmx)")
(td (~tw :tokens "py-2 px-3") "SSR + hydrated islands"))
(tr
(~tw :tokens "border-b border-stone-100")
(td
(~tw :tokens "py-2 px-3 font-semibold text-stone-700")
"Client Rendering")
(td (~tw :tokens "py-2 px-3") "SX wire format (current)")
(td
(~tw :tokens "py-2 px-3 font-semibold text-violet-700")
"Reactive islands (this)")))))
(p
"Most content stays pure hypermedia. Interactive regions opt into reactivity. The author controls where each component sits on both bars."))
(~docs/section
:title "Four Levels"
:id "levels"
(div
(~tw :tokens "space-y-4")
(div
(~tw :tokens "rounded border border-stone-200 p-4")
(div
(~tw :tokens "font-semibold text-stone-800")
"Level 0: Pure Hypermedia")
(p
(~tw :tokens "text-sm text-stone-600 mt-1")
"The default. "
(code "sx-get")
", "
(code "sx-post")
", "
(code "sx-swap")
". Server renders everything. No client state. 90% of a typical application."))
(div
(~tw :tokens "rounded border border-stone-200 p-4")
(div
(~tw :tokens "font-semibold text-stone-800")
"Level 1: Local DOM Operations")
(p
(~tw :tokens "text-sm text-stone-600 mt-1")
"Imperative escapes: "
(code "toggle!")
", "
(code "set-attr!")
", "
(code "on-event")
". Micro-interactions too small for a server round-trip."))
(div
(~tw :tokens "rounded border border-violet-300 bg-violet-50 p-4")
(div
(~tw :tokens "font-semibold text-violet-900")
"Level 2: Reactive Islands")
(p
(~tw :tokens "text-sm text-stone-600 mt-1")
(code "defisland")
" components with local signals. Fine-grained DOM updates "
(em "without")
" virtual DOM, diffing, or component re-renders. A signal change updates only the DOM nodes that read it."))
(div
(~tw :tokens "rounded border border-stone-200 p-4")
(div
(~tw :tokens "font-semibold text-stone-800")
"Level 3: Connected Islands")
(p
(~tw :tokens "text-sm text-stone-600 mt-1")
"Islands that share state via signal props or named stores ("
(code "def-store")
" / "
(code "use-store")
")."))))
(~docs/section
:title "Signal Primitives"
:id "signals"
(~docs/code
:src (highlight
"(signal v) ;; create a reactive container\n(deref s) ;; read value — subscribes in reactive context\n(reset! s v) ;; write new value — notifies subscribers\n(swap! s f) ;; update via function: (f old-value)\n(computed fn) ;; derived signal — auto-tracks dependencies\n(effect fn) ;; side effect — re-runs when deps change\n(batch fn) ;; group writes — one notification pass"
"lisp"))
(p
"Signals are values, not hooks. Create them anywhere — conditionals, loops, closures. No rules of hooks. Pass them as arguments, store them in dicts, share between islands."))
(~docs/section
:title "Island Lifecycle"
:id "lifecycle"
(ol
(~tw :tokens "space-y-2 text-stone-600 list-decimal list-inside")
(li
(strong "Definition: ")
(code "defisland")
" registers a reactive component (like "
(code "defcomp")
" + island flag)")
(li
(strong "Server render: ")
"Body evaluated with initial values. "
(code "deref")
" returns plain value. Output wrapped in "
(code "data-sx-island")
" / "
(code "data-sx-state"))
(li
(strong "Client hydration: ")
"Finds "
(code "data-sx-island")
" elements, creates signals from serialized state, re-renders in reactive context")
(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
(~tw :tokens "space-y-2 mt-3")
(div
(~tw :tokens "rounded border border-green-200 bg-green-50 p-3")
(div
(~tw :tokens "font-semibold text-green-800 text-sm")
"Swap inside island")
(p
(~tw :tokens "text-sm text-stone-600 mt-1")
"Lake content replaced. Signals survive. Effects rebind to new DOM."))
(div
(~tw :tokens "rounded border border-green-200 bg-green-50 p-3")
(div
(~tw :tokens "font-semibold text-green-800 text-sm")
"Swap outside island")
(p
(~tw :tokens "text-sm text-stone-600 mt-1")
"Different part of page updated. Island completely unaffected."))
(div
(~tw :tokens "rounded border border-amber-200 bg-amber-50 p-3")
(div
(~tw :tokens "font-semibold text-amber-800 text-sm")
"Swap replaces island")
(p
(~tw :tokens "text-sm text-stone-600 mt-1")
"Island disposed. Local signals lost. Named stores persist — new island reconnects via "
(code "use-store")
"."))
(div
(~tw :tokens "rounded border border-stone-200 p-3")
(div
(~tw :tokens "font-semibold text-stone-800 text-sm")
"Full page navigation")
(p
(~tw :tokens "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
:src (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
:src (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 "Examples"
:id "examples"
(p "Each example below shows a live island and its source.")
(~docs/section
:title "Counter"
:id "demo-counter"
(p "Signals, computed, and " (code "swap!") ".")
(~reactive-islands/index/demo-counter :initial 0)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-counter")
"lisp")))
(~docs/section
:title "Temperature Converter"
:id "demo-temperature"
(p
"Two signals, each derived from the other via "
(code "effect")
".")
(~reactive-islands/index/demo-temperature)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-temperature")
"lisp")))
(~docs/section
:title "Imperative Handlers"
:id "demo-imperative"
(p "Multi-statement " (code "(do ...)") " bodies in event handlers.")
(~reactive-islands/index/demo-imperative)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-imperative")
"lisp")))
(~docs/section
:title "Stopwatch"
:id "demo-stopwatch"
(p
(code "set-interval")
" and "
(code "clear-interval")
" with signal-driven UI.")
(~reactive-islands/index/demo-stopwatch)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-stopwatch")
"lisp")))
(~docs/section
:title "Reactive List"
:id "demo-reactive-list"
(p "Dynamic list with keyed reconciliation.")
(~reactive-islands/index/demo-reactive-list)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-reactive-list")
"lisp")))
(~docs/section
:title "Input Binding"
:id "demo-input-binding"
(p "Two-way binding via " (code ":bind") " attribute.")
(~reactive-islands/index/demo-input-binding)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-input-binding")
"lisp")))
(~docs/section
:title "Dynamic Classes"
:id "demo-dynamic-class"
(p
"Reactive class toggling with "
(code "deref")
" in attribute expressions.")
(~reactive-islands/index/demo-dynamic-class)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-dynamic-class")
"lisp")))
(~docs/section
:title "Portal"
:id "demo-portal"
(p "Render content outside the island's DOM subtree.")
(~reactive-islands/index/demo-portal)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-portal")
"lisp")))
(~docs/section
:title "Error Boundary"
:id "demo-error-boundary"
(p "Catch rendering errors without crashing the page.")
(~reactive-islands/index/demo-error-boundary)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-error-boundary")
"lisp")))
(~docs/section
:title "DOM Refs"
:id "demo-refs"
(p "Access raw DOM elements via " (code ":ref") " signal.")
(~reactive-islands/index/demo-refs)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-refs")
"lisp")))
(~docs/section
:title "Resource"
:id "demo-resource"
(p "Async data fetching with loading state.")
(~reactive-islands/index/demo-resource)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-resource")
"lisp")))
(~docs/section
:title "Transition"
:id "demo-transition"
(p
"Debounced search with "
(code "schedule-idle")
" and "
(code "batch")
".")
(~reactive-islands/index/demo-transition)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-transition")
"lisp")))
(~docs/section
:title "Named Stores"
:id "demo-stores"
(p
"Two islands sharing state via "
(code "def-store")
" / "
(code "use-store")
".")
(~reactive-islands/index/demo-store-writer)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-store-writer")
"lisp"))
(~reactive-islands/index/demo-store-reader)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-store-reader")
"lisp")))
(~docs/section
:title "Event Bridge"
:id "demo-event-bridge"
(p
"Server-rendered content communicating with an island via "
(code "bridge-event")
".")
(~reactive-islands/index/demo-event-bridge)
(~docs/code
:src (highlight
(component-source "~reactive-islands/index/demo-event-bridge")
"lisp"))))
(~docs/section
:title "Design Principles"
:id "principles"
(ol
(~tw :tokens "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
(~tw :tokens "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.")
(div
(~tw :tokens "overflow-x-auto rounded border border-stone-200")
(table
(~tw :tokens "w-full text-left text-sm")
(thead
(tr
(~tw :tokens "border-b border-stone-200 bg-stone-100")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Layer")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Status")
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Files")))
(tbody
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Signal runtime spec")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"signals.sx (291 lines)"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "defisland special form")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"eval.sx, special-forms.sx, render.sx"))
(tr
(~tw :tokens "border-b border-stone-100")
(td
(~tw :tokens "px-3 py-2 text-stone-700")
"DOM adapter (reactive rendering)")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"adapter-dom.sx (+140 lines)"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "HTML adapter (SSR)")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"adapter-html.sx (+65 lines)"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "JS bootstrapper")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"bootstrap_js.py, sx-ref.js (4769 lines)"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Python bootstrapper")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"bootstrap_py.py, sx_ref.py"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Test suite")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "17/17")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"test-signals.sx"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Named stores (L3)")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Spec'd")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"signals.sx: def-store, use-store, clear-stores"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Event bridge")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Spec'd")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"signals.sx: emit-event, on-event, bridge-event"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Client hydration")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Spec'd")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"boot.sx: sx-hydrate-islands, hydrate-island, dispose-island"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Event bindings")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Spec'd")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"adapter-dom.sx: :on-click → domListen"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "data-sx-emit processing")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Spec'd")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"orchestration.sx: process-emit-elements"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Island disposal")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"boot.sx, orchestration.sx: dispose-islands-in pre-swap"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Reactive list")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"adapter-dom.sx: map + deref auto-upgrades"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Input binding")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"adapter-dom.sx: :bind signal, bind-input"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Keyed reconciliation")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"adapter-dom.sx: :key attr, extract-key"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Portals")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"adapter-dom.sx: portal render-dom form"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Error boundaries")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"adapter-dom.sx: error-boundary render-dom form"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Resource (async signal)")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"signals.sx: resource, promise-then"))
(tr
(~tw :tokens "border-b border-stone-100")
(td (~tw :tokens "px-3 py-2 text-stone-700") "Suspense pattern")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"resource + cond/deref (no special form)"))
(tr
(td (~tw :tokens "px-3 py-2 text-stone-700") "Transition pattern")
(td (~tw :tokens "px-3 py-2 text-green-700 font-medium") "Done")
(td
(~tw :tokens "px-3 py-2 font-mono text-xs text-stone-500")
"schedule-idle + batch (no special form)"))))))))