Files
rose-ash/sx/sx/reactive-runtime.sx
giles fea44f9fcc Add reactive runtime demos + sx-web federation plan
7 interactive island demos for the reactive runtime layers:
- L0 Ref (timer + DOM handle), L1 Foreign (canvas via host-call),
  L2 Machine (traffic light), L3 Commands (undo/redo),
  L4 Loop (bouncing ball), L5 Keyed Lists, L6 App Shell

Fix OCaml build: add (wrapped false) to lib/dune, remove Sx. qualifiers.
Fix JS build: include dom-lib + browser-lib in adapter compilation.

New plan: sx-web federated component web — browser nodes via WebTransport,
server nodes via IPFS, in-browser authoring, AI composition layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 00:54:23 +00:00

703 lines
47 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
;; Reactive Application Runtime — 7 Feature Layers
;; Zero new platform primitives. All functions composing existing signals + DOM ops.
;; Lives under Applications: /(applications.(reactive-runtime))
;; ---------------------------------------------------------------------------
;; Overview page
;; ---------------------------------------------------------------------------
(defcomp ~reactive-runtime/overview-content ()
(~docs/page :title "Reactive Application Runtime"
(p :class "text-stone-500 text-sm italic mb-8"
"Seven feature layers that take SX from reactive documents to a full application runtime. "
"Every layer is a pure SX function composing existing primitives — zero new CEK frames, "
"zero new platform primitives. The existing platform interface is sufficient "
"for any application pattern.")
;; =====================================================================
;; Motivation
;; =====================================================================
(~docs/section :title "Motivation" :id "motivation"
(p "SX has signals, computed, effects, batch, islands, lakes, stores, and bridge events — "
"sufficient for reactive documents (forms, toggles, counters, live search). "
"But complex client-heavy apps (drawing tools, editors, games) need structure on top:")
(table :class "w-full mb-6 text-sm"
(thead
(tr (th :class "text-left p-2" "Have") (th :class "text-left p-2" "Need")))
(tbody
(tr (td :class "p-2" "Signal holds a value") (td :class "p-2" "Ref holds a value " (em "without") " reactivity"))
(tr (td :class "p-2" "Effect runs on change") (td :class "p-2" "Loop runs " (em "continuously")))
(tr (td :class "p-2" "Store shares state") (td :class "p-2" "Machine manages " (em "modal") " state"))
(tr (td :class "p-2" "reset!/swap! update") (td :class "p-2" "Commands update " (em "with history")))
(tr (td :class "p-2" "DOM rendering") (td :class "p-2" "Foreign calls " (em "any host API")))
(tr (td :class "p-2" "Server-first hydration") (td :class "p-2" "Client-first " (em "app shell"))))))
;; =====================================================================
;; The Seven Layers
;; =====================================================================
(~docs/section :title "The Seven Layers" :id "layers"
(table :class "w-full mb-6 text-sm"
(thead
(tr (th :class "text-left p-2" "Layer") (th :class "text-left p-2" "What") (th :class "text-left p-2" "Builds On")))
(tbody
(tr (td :class "p-2 font-mono" "L0") (td :class "p-2" "Ref — mutable box without reactivity") (td :class "p-2" "register-in-scope"))
(tr (td :class "p-2 font-mono" "L1") (td :class "p-2" "Foreign — host API interop") (td :class "p-2" "host-call, host-get, host-set!"))
(tr (td :class "p-2 font-mono" "L2") (td :class "p-2" "State machines — modal state as signal") (td :class "p-2" "signal, deref, reset!"))
(tr (td :class "p-2 font-mono" "L3") (td :class "p-2" "Commands — undo/redo signal stores") (td :class "p-2" "signal, computed, L0"))
(tr (td :class "p-2 font-mono" "L4") (td :class "p-2" "Render loop — continuous rAF") (td :class "p-2" "L0, request-animation-frame"))
(tr (td :class "p-2 font-mono" "L5") (td :class "p-2" "Keyed lists — explicit key reconciliation") (td :class "p-2" "reactive-list (existing)"))
(tr (td :class "p-2 font-mono" "L6") (td :class "p-2" "App shell — client-first rendering") (td :class "p-2" "sx-mount, all above")))))
;; =====================================================================
;; Implementation Plan
;; =====================================================================
(~docs/section :title "Implementation Plan" :id "implementation"
(p "All seven layers live in a single spec module: " (code "web/reactive-runtime.sx") ". "
"Every layer is a plain " (code "define") " function — no macros, no new special forms. "
"The OCaml evaluator bootstraps them to JavaScript via the standard transpiler pipeline.")
(~docs/subsection :title "Implementation Order"
(~docs/code :src (highlight
"L0 Ref → standalone, trivial (~35 LOC)\nL1 Foreign FFI → standalone, function factories (~100 LOC)\nL5 Keyed Lists → enhances existing reactive-list (~155 LOC)\nL2 State Machine → uses signals + dicts (~200 LOC)\nL4 Render Loop → uses L0 refs + existing rAF (~140 LOC)\nL3 Commands → extends stores, uses signals (~320 LOC)\nL6 App Shell → orchestrates all above (~330 LOC)\n Total: ~1280 LOC"
"text"))
(p "L0 and L1 are independent foundations. L5 enhances existing code. "
"L2 and L4 depend on L0. L3 builds on signals. L6 ties everything together."))
(~docs/subsection :title "Build Integration"
(p "One new entry in " (code "SPEC_MODULES") " (hosts/javascript/platform.py):")
(~docs/code :src (highlight
"SPEC_MODULES = {\n ...\n \"reactive-runtime\": (\"reactive-runtime.sx\", \"reactive-runtime (application patterns)\"),\n}\nSPEC_MODULE_ORDER = [..., \"reactive-runtime\"]"
"python"))
(p "Auto-included when the " (code "dom") " adapter is present. "
"Depends on " (code "signals") " (loaded first via module ordering)."))
(~docs/subsection :title "Existing Primitives Used"
(table :class "w-full mb-6 text-sm"
(thead
(tr (th :class "text-left p-2" "Primitive") (th :class "text-left p-2" "Used By")))
(tbody
(tr (td :class "p-2" (code "signal, deref, reset!, swap!, computed, effect")) (td :class "p-2" "L2, L3, L4"))
(tr (td :class "p-2" (code "host-call, host-get, host-set!")) (td :class "p-2" "L1 (Foreign FFI)"))
(tr (td :class "p-2" (code "request-animation-frame")) (td :class "p-2" "L4 (Render Loop)"))
(tr (td :class "p-2" (code "register-in-scope, scope-push!, scope-pop!")) (td :class "p-2" "L0, L4"))
(tr (td :class "p-2" (code "sx-mount, sx-hydrate-islands")) (td :class "p-2" "L6 (App Shell)"))
(tr (td :class "p-2" (code "DOM ops (dom-insert-after, dom-remove, …)")) (td :class "p-2" "L5 (Keyed Lists)"))))
(p :class "text-stone-600 italic"
"Zero new platform primitives validates that the existing interface is complete "
"for any application pattern.")))))
;; ---------------------------------------------------------------------------
;; L0: Ref
;; ---------------------------------------------------------------------------
(defcomp ~reactive-runtime/ref-content ()
(~docs/page :title "Layer 0: Ref (Mutable Box)"
(p :class "text-stone-500 text-sm italic mb-8"
"A non-reactive mutable cell for DOM handles, canvas contexts, and timer IDs. "
"Like a signal but with no subscriber tracking and no notifications.")
(~docs/section :title "Live Demo" :id "demo"
(p "Two patterns that refs formalize: an interval ID captured in an effect closure, and a DOM element handle stored in a dict. Neither needs reactivity — they're infrastructure state.")
(~reactive-runtime/demo-ref)
(~docs/code :src (highlight "(defisland ~demo-ref ()\n (let ((input-ref (dict \"current\" nil))\n (ticks (signal 0))\n (running (signal false))\n (last-value (signal \"\")))\n ;; Timer ID captured in effect closure — the ref pattern\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! ticks inc)) 500)))\n (fn () (clear-interval id))))))\n ;; DOM handle in a dict — also the ref pattern\n (input :ref input-ref :type \"text\" ...)\n (button :on-click (fn (e)\n (reset! last-value\n (dom-get-prop (get input-ref \"current\") \"value\")))\n \"Read\")))" "lisp"))
(p "The timer ID lives in a closure — nothing reads it reactively. The DOM element is a " (code "(dict \"current\" nil)") " set by " (code ":ref") ". The " (code "ref") " function formalizes both into a single primitive with auto-disposal."))
(~docs/section :title "API" :id "ref-api"
(~docs/code :src (highlight
";; Create a ref — auto-registers disposal in island scope\n(define my-ref (ref initial-value))\n\n;; Read (no tracking, no subscriptions)\n(ref-deref my-ref)\n\n;; Write (no notifications, no re-renders)\n(ref-set! my-ref new-value)\n\n;; Predicate\n(ref? my-ref) ;; => true"
"lisp"))
(p "Plain dicts with a " (code "__ref") " marker key (mirrors the " (code "__signal") " pattern). "
"On island disposal, the ref value is auto-nilled via " (code "register-in-scope") "."))
(~docs/section :title "When to Use Ref vs Signal" :id "ref-vs-signal"
(table :class "w-full mb-6 text-sm"
(thead
(tr (th :class "text-left p-2" "Use Ref") (th :class "text-left p-2" "Use Signal")))
(tbody
(tr (td :class "p-2" "DOM element handle") (td :class "p-2" "UI state that triggers re-render"))
(tr (td :class "p-2" "Canvas 2D context") (td :class "p-2" "Form input value"))
(tr (td :class "p-2" "Timer/interval ID") (td :class "p-2" "Counter, toggle, selection"))
(tr (td :class "p-2" "WebSocket connection") (td :class "p-2" "Anything the UI reads via deref")))))
(~docs/section :title "Implementation" :id "ref-impl"
(~docs/code :src (highlight
"(define make-ref (fn (value)\n (dict \"__ref\" true \"value\" value)))\n\n(define ref? (fn (x)\n (and (dict? x) (has-key? x \"__ref\"))))\n\n(define ref (fn (initial-value)\n (let ((r (make-ref initial-value)))\n (register-in-scope (fn () (dict-set! r \"value\" nil)))\n r)))\n\n(define ref-deref (fn (r) (get r \"value\")))\n(define ref-set! (fn (r v) (dict-set! r \"value\" v)))"
"lisp"))
(p "~35 lines. No dependencies beyond " (code "register-in-scope") " from signals.sx."))))
;; ---------------------------------------------------------------------------
;; L1: Foreign
;; ---------------------------------------------------------------------------
(defcomp ~reactive-runtime/foreign-content ()
(~docs/page :title "Layer 1: Foreign (Host API Interop)"
(p :class "text-stone-500 text-sm italic mb-8"
"Clean boundary for calling host APIs — Canvas, WebGL, WebAudio, any browser API — "
"from SX code. Function factories with automatic kebab-to-camelCase conversion.")
(~docs/section :title "Live Demo" :id "demo"
(p "Click the canvas to place colored rectangles. Uses " (code "host-call") " for canvas 2D context method calls and " (code "host-set!") " for property writes — the raw primitives that " (code "foreign-method") " and " (code "foreign-prop-setter") " will wrap.")
(~reactive-runtime/demo-foreign)
(~docs/code :src (highlight "(defisland ~demo-foreign ()\n (let ((canvas-ref (dict \"current\" nil))\n (color (signal \"#8b5cf6\"))\n (rects (signal (list))))\n ;; Draw effect — re-runs when rects changes\n (effect (fn ()\n (let ((el (get canvas-ref \"current\")))\n (when el\n (let ((ctx (host-call el \"getContext\" \"2d\")))\n (host-call ctx \"clearRect\" 0 0 280 160)\n (for-each (fn (r)\n (host-set! ctx \"fillStyle\" (get r \"c\"))\n (host-call ctx \"fillRect\" (get r \"x\") (get r \"y\") 20 20))\n (deref rects)))))))\n (canvas :ref canvas-ref :on-click (fn (e) ...)\n :width \"280\" :height \"160\")))" "lisp"))
(p "Every canvas operation is a raw " (code "host-call") " or " (code "host-set!") ". The foreign FFI layer wraps these into named SX functions: " (code "(fill-rect ctx 0 0 280 160)") " instead of " (code "(host-call ctx \"fillRect\" 0 0 280 160)") "."))
(~docs/section :title "API" :id "foreign-api"
(~docs/code :src (highlight
";; Function factories — each returns a reusable function\n(define fill-rect (foreign-method \"fill-rect\"))\n(define set-fill-style! (foreign-prop-setter \"fill-style\"))\n(define get-width (foreign-prop-getter \"width\"))\n\n;; Usage — clean SX calls, no string method names\n(let ((ctx (host-call canvas \"getContext\" \"2d\")))\n (set-fill-style! ctx \"red\")\n (fill-rect ctx 0 0 (get-width canvas) 100))"
"lisp"))
(p "Three factory functions, one helper:")
(ul :class "list-disc pl-6 mb-4 space-y-1"
(li (code "foreign-method") " — wraps " (code "host-call") " with kebab→camel conversion")
(li (code "foreign-prop-getter") " — wraps " (code "host-get"))
(li (code "foreign-prop-setter") " — wraps " (code "host-set!"))
(li (code "kebab->camel") " — " (code "\"fill-rect\"") " → " (code "\"fillRect\""))))
(~docs/section :title "Implementation" :id "foreign-impl"
(~docs/code :src (highlight
"(define kebab->camel (fn (s)\n ;; \"fill-rect\" → \"fillRect\", \"font-size\" → \"fontSize\"\n (let ((parts (split s \"-\"))\n (first-part (first parts))\n (rest-parts (rest parts)))\n (str first-part\n (join \"\" (map (fn (p)\n (str (upper (slice p 0 1)) (slice p 1))) rest-parts))))))\n\n(define foreign-method (fn (method-name)\n (let ((camel (kebab->camel method-name)))\n (fn (obj &rest args)\n (apply host-call (concat (list obj camel) args))))))\n\n(define foreign-prop-getter (fn (prop-name)\n (let ((camel (kebab->camel prop-name)))\n (fn (obj) (host-get obj camel)))))\n\n(define foreign-prop-setter (fn (prop-name)\n (let ((camel (kebab->camel prop-name)))\n (fn (obj val) (host-set! obj camel val)))))"
"lisp"))
(p "~100 lines. Uses existing host FFI primitives — no new platform interface needed."))))
;; ---------------------------------------------------------------------------
;; L2: State Machines
;; ---------------------------------------------------------------------------
(defcomp ~reactive-runtime/machine-content ()
(~docs/page :title "Layer 2: State Machines"
(p :class "text-stone-500 text-sm italic mb-8"
"Modal state management where machine state IS a signal. "
"Composes naturally with computed and effects.")
(~docs/section :title "Live Demo" :id "demo"
(p "A traffic light built from a signal and a transitions dict. Auto-advance uses an effect with " (code "set-timeout") " — when state changes, the effect re-runs and schedules the next transition.")
(~reactive-runtime/demo-machine)
(~docs/code :src (highlight "(defisland ~demo-machine ()\n (let ((state (signal \"red\"))\n (transitions (dict \"red\" \"green\" \"green\" \"yellow\" \"yellow\" \"red\"))\n (auto (signal false)))\n ;; Auto-advance: effect depends on both state and auto\n (effect (fn ()\n (when (deref auto)\n (let ((delay (if (= (deref state) \"yellow\") 1000 2500)))\n (let ((id (set-timeout\n (fn () (reset! state (get transitions (deref state))))\n delay)))\n (fn () (clear-timeout id)))))))\n ;; Display: reactive class on each light\n (div :class (str \"...\" (if (= (deref state) \"red\")\n \"bg-red-500\" \"bg-red-900/30\")) ...)))" "lisp"))
(p "The machine state is a signal — " (code "(deref state)") " in the effect body subscribes, so when the timeout fires and resets state, the effect re-runs with the new delay. The " (code "make-machine") " function wraps this pattern with transition tables, guards, and enter/exit hooks."))
(~docs/section :title "API" :id "machine-api"
(~docs/code :src (highlight
"(define drawing-tool (make-machine\n {:initial :idle\n :states {:idle {:on {:pointer-down (fn (ev)\n {:state :drawing\n :actions (list (fn () (start-shape! ev)))})}}\n :drawing {:on {:pointer-move (fn (ev)\n {:state :drawing\n :actions (list (fn () (update-shape! ev)))})\n :pointer-up (fn (ev)\n {:state :idle\n :actions (list (fn () (finish-shape! ev)))})}}}}))\n\n;; Send events\n(machine-send! drawing-tool :pointer-down event)\n\n;; Read state (reactive — triggers re-render)\n(deref (machine-state drawing-tool)) ;; => :drawing\n(machine-matches? drawing-tool :idle) ;; => false"
"lisp"))
(p (code "make-machine") " returns a dict with a state signal, transitions map, and send function. "
"Handlers return " (code "{:state :new-state :actions [fn ...]}") " — "
"actions are called after the state transition."))
(~docs/section :title "Implementation Sketch" :id "machine-impl"
(~docs/code :src (highlight
"(define make-machine (fn (config)\n (let ((current (signal (get config :initial)))\n (states (get config :states)))\n (dict\n \"__machine\" true\n \"state\" current\n \"states\" states))))\n\n(define machine-state (fn (m) (get m \"state\")))\n\n(define machine-matches? (fn (m s)\n (= (deref (get m \"state\")) s)))\n\n(define machine-send! (fn (m event &rest data)\n (let ((current-state (deref (get m \"state\")))\n (state-config (get (get m \"states\") current-state))\n (handlers (get state-config :on))\n (handler (get handlers event)))\n (when handler\n (let ((result (apply handler data)))\n (when (get result :state)\n (reset! (get m \"state\") (get result :state)))\n (when (get result :actions)\n (for-each (fn (action) (action))\n (get result :actions))))))))"
"lisp"))
(p "~200 lines with guard conditions, context data, and enter/exit hooks."))))
;; ---------------------------------------------------------------------------
;; L3: Commands with History
;; ---------------------------------------------------------------------------
(defcomp ~reactive-runtime/commands-content ()
(~docs/page :title "Layer 3: Commands with History"
(p :class "text-stone-500 text-sm italic mb-8"
"Command pattern built into signal stores. Commands are s-expressions "
"in a history stack — trivially serializable, with transaction grouping for drag operations.")
(~docs/section :title "Live Demo" :id "demo"
(p "A counter with full undo/redo. Each operation pushes the previous state onto the undo stack. Undo pops from undo and pushes to redo. All state is reactive — buttons disable when their stack is empty.")
(~reactive-runtime/demo-commands)
(~docs/code :src (highlight "(defisland ~demo-commands ()\n (let ((value (signal 0))\n (undo-stack (signal (list)))\n (redo-stack (signal (list)))\n (do-cmd (fn (new-val)\n (batch (fn ()\n (swap! undo-stack (fn (s) (cons (deref value) s)))\n (reset! redo-stack (list))\n (reset! value new-val)))))\n (undo (fn ()\n (when (> (len (deref undo-stack)) 0)\n (batch (fn ()\n (let ((prev (first (deref undo-stack))))\n (swap! redo-stack (fn (r) (cons (deref value) r)))\n (reset! value prev)\n (swap! undo-stack rest)))))))\n (redo (fn () ...)))\n (span (deref value))\n (button :on-click (fn (e) (do-cmd (+ (deref value) 1))) \"+1\")\n (button :on-click (fn (e) (undo)) \"Undo\")))" "lisp"))
(p "Three signals and two functions. " (code "batch") " groups the three writes (push old, clear redo, set new) into one notification pass. The stacks use " (code "cons") "/" (code "first") "/" (code "rest") " — standard list operations. " (code "make-command-store") " wraps this pattern and adds transaction grouping."))
(~docs/section :title "API" :id "commands-api"
(~docs/code :src (highlight
"(define canvas-state (make-command-store\n {:initial {:elements (list) :selection nil}\n :commands {:add-element (fn (state el)\n (assoc state :elements\n (append (get state :elements) (list el))))\n :move-element (fn (state id dx dy) ...)}\n :max-history 100}))\n\n;; Dispatch commands\n(cmd-dispatch! canvas-state :add-element rect-1)\n\n;; Undo / redo\n(cmd-undo! canvas-state)\n(cmd-redo! canvas-state)\n\n;; Reactive predicates\n(deref (cmd-can-undo? canvas-state)) ;; => true\n(deref (cmd-can-redo? canvas-state)) ;; => false\n\n;; Transaction grouping — collapses into single undo entry\n(cmd-group-start! canvas-state \"drag\")\n(cmd-dispatch! canvas-state :move-element id 1 0)\n(cmd-dispatch! canvas-state :move-element id 1 0)\n(cmd-dispatch! canvas-state :move-element id 1 0)\n(cmd-group-end! canvas-state)\n;; One undo reverses all three moves"
"lisp")))
(~docs/section :title "Internals" :id "commands-internals"
(p "A command store wraps three signals and a ref:")
(ul :class "list-disc pl-6 mb-4 space-y-1"
(li (strong "state") " signal — current application state")
(li (strong "undo-stack") " signal — list of previous states")
(li (strong "redo-stack") " signal — list of undone states")
(li (strong "group") " ref — current transaction label (nil when not grouping)"))
(p (code "cmd-dispatch!") " applies the command function to the current state, "
"pushes the old state onto the undo stack, and clears the redo stack. "
"During a group, intermediate states are collapsed so only the pre-group "
"state appears on the undo stack.")
(p "~320 lines. The most complex layer, but pure signals + dicts."))))
;; ---------------------------------------------------------------------------
;; L4: Render Loop
;; ---------------------------------------------------------------------------
(defcomp ~reactive-runtime/loop-content ()
(~docs/page :title "Layer 4: Continuous Rendering Loop"
(p :class "text-stone-500 text-sm italic mb-8"
(code "requestAnimationFrame") " integration for canvas and animation apps, "
"with automatic island lifecycle cleanup.")
(~docs/section :title "Live Demo" :id "demo"
(p "A ball bouncing across a track. Position is a signal — the " (code ":style") " attribute reads it reactively. The animation runs via " (code "set-interval") " at ~60fps; the effect's cleanup stops it when paused.")
(~reactive-runtime/demo-loop)
(~docs/code :src (highlight "(defisland ~demo-loop ()\n (let ((running (signal true))\n (x (signal 0))\n (dir (signal 1))\n (frames (signal 0)))\n ;; Animation effect — interval simulates rAF\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval\n (fn ()\n (batch (fn ()\n (swap! frames inc)\n (when (>= (deref x) 230) (reset! dir -1))\n (when (<= (deref x) 0) (reset! dir 1))\n (swap! x (fn (v) (+ v (* (deref dir) 3)))))))\n 16)))\n (fn () (clear-interval id))))))\n ;; Ball position driven by reactive style\n (div :class \"relative h-12 ...\" :style (str \"...\")\n (div :style (str \"left:\" (deref x) \"px\") ...))))" "lisp"))
(p "The " (code "running") " signal is read in the effect body — toggling it triggers cleanup (stops the interval) and re-evaluation (starts a new one or does nothing). The real " (code "make-loop") " uses " (code "request-animation-frame") " with the running-ref pattern instead."))
(~docs/section :title "API" :id "loop-api"
(~docs/code :src (highlight
"(define render-loop (make-loop (fn (timestamp dt)\n (let ((ctx (ref-deref ctx-ref)))\n (clear-canvas! ctx)\n (draw-scene! ctx (deref elements))))))\n\n(loop-start! render-loop)\n(loop-stop! render-loop)\n(deref (loop-running? render-loop)) ;; => true (reactive)"
"lisp")))
(~docs/section :title "Running-Ref Pattern" :id "loop-pattern"
(~docs/code :src (highlight
";; Internal: no cancelAnimationFrame needed\n(let ((running (ref true))\n (last-ts (ref 0)))\n (define tick (fn (ts)\n (when (ref-deref running)\n (let ((dt (- ts (ref-deref last-ts))))\n (ref-set! last-ts ts)\n (user-fn ts dt))\n (request-animation-frame tick))))\n (request-animation-frame tick)\n ;; To stop: (ref-set! running false)\n ;; Loop dies naturally on next frame check"
"lisp"))
(p "The loop checks a ref each frame. Setting it to " (code "false") " stops the loop "
"without needing " (code "cancelAnimationFrame") ". On island disposal, "
(code "register-in-scope") " auto-stops the loop.")
(p "~140 lines. Depends on L0 (refs) and " (code "request-animation-frame")
" from web/lib/browser.sx."))))
;; ---------------------------------------------------------------------------
;; L5: Keyed Lists
;; ---------------------------------------------------------------------------
(defcomp ~reactive-runtime/keyed-lists-content ()
(~docs/page :title "Layer 5: Keyed List Reconciliation"
(p :class "text-stone-500 text-sm italic mb-8"
"Enhances the existing reactive-list with explicit key extraction callbacks "
"for stable identity tracking across updates.")
(~docs/section :title "Live Demo" :id "demo"
(p "Items with stable " (code ":key") " identities. Reverse and rotate reorder the signal list — the reconciler moves existing DOM nodes instead of recreating them. Add and remove demonstrate insertion and deletion.")
(~reactive-runtime/demo-keyed-lists)
(~docs/code :src (highlight "(defisland ~demo-keyed-lists ()\n (let ((items (signal (list\n (dict \"id\" 1 \"text\" \"Alpha\" \"color\" \"violet\")\n (dict \"id\" 2 \"text\" \"Beta\" \"color\" \"blue\") ...)))\n (next-id (signal 6)))\n (button :on-click (fn (e) (swap! items reverse)) \"Reverse\")\n (button :on-click (fn (e)\n (swap! items (fn (old) (append (rest old) (first old)))))\n \"Rotate\")\n (ul (map (fn (item)\n (li :key (str (get item \"id\"))\n :class (str \"bg-\" (get item \"color\") \"-100 ...\")\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items)))))" "lisp"))
(p (code ":key") " on each " (code "li") " gives the reconciler stable identity. When " (code "reverse") " flips the list, the existing DOM nodes are moved — not destroyed and recreated. This preserves focus, scroll position, CSS transitions, and internal island state."))
(~docs/section :title "API" :id "keyed-api"
(~docs/code :src (highlight
";; Current: keys extracted from rendered DOM key attribute\n(map (fn (el) (~shape-handle :key (get el :id) el)) (deref items))\n\n;; Enhanced: explicit key function\n(map (fn (el) (~shape-handle el)) (deref items)\n :key (fn (el) (get el :id)))"
"lisp"))
(p "The " (code ":key") " parameter provides a function that extracts a stable identity "
"from each item " (em "before") " rendering. This is more efficient than extracting keys "
"from rendered DOM nodes and handles cases where the rendered output doesn't have a "
"natural key attribute."))
(~docs/section :title "Changes" :id "keyed-changes"
(p "Enhancement to existing " (code "adapter-dom.sx") ":")
(ul :class "list-disc pl-6 mb-4 space-y-1"
(li (code "render-dom-list") " detection — parse " (code ":key") " kwarg after " (code "(deref sig)"))
(li (code "reactive-list") " — accept optional " (code "key-fn") ", use instead of " (code "extract-key") " when provided")
(li "Fully backward compatible — without " (code ":key") ", behavior unchanged"))
(p "~155 lines of changes. No new platform primitives — existing DOM ops suffice."))))
;; ---------------------------------------------------------------------------
;; L6: App Shell
;; ---------------------------------------------------------------------------
(defcomp ~reactive-runtime/app-shell-content ()
(~docs/page :title "Layer 6: Client-First App Shell"
(p :class "text-stone-500 text-sm italic mb-8"
"Skip SSR entirely for canvas-heavy apps. Server returns a minimal HTML shell; "
"all rendering happens client-side.")
(~docs/section :title "Live Demo" :id "demo"
(p "A mini single-page app rendered entirely client-side. Navigation switches views via a " (code "route") " signal — no server round-trips. Counter state persists across view switches because signals live in closures, not the DOM.")
(~reactive-runtime/demo-app-shell)
(~docs/code :src (highlight "(defisland ~demo-app-shell ()\n (let ((route (signal \"home\"))\n (count (signal 0)))\n ;; Client-side nav\n (nav\n (button :on-click (fn (e) (reset! route \"home\"))\n :class (str ... (if (= (deref route) \"home\") \"active\" \"inactive\"))\n \"Home\")\n ...)\n ;; Reactive view switching\n (cond\n (= (deref route) \"home\")\n (div (p \"Client-rendered. No server round-trip.\"))\n (= (deref route) \"counter\")\n (div (span (deref count)) ...)\n (= (deref route) \"about\")\n (div (p \"Entirely client-rendered.\")))))" "lisp"))
(p "This island IS a mini app shell. The " (code "make-app") " function generalizes the pattern: server returns " (code "<div id=\"sx-app-root\">") " + SX loader, " (code "app-boot") " mounts the entry component, " (code "app-navigate!") " switches routes — all using existing " (code "sx-mount") " and signals."))
(~docs/section :title "API" :id "app-api"
(~docs/code :src (highlight
"(define my-app (make-app\n {:entry ~drawing-app\n :stores (list canvas-state tool-state)\n :routes {\"/\" ~drawing-app\n \"/gallery\" ~gallery-view}\n :head (list (link :rel \"stylesheet\" :href \"/static/app.css\"))}))\n\n;; Server: generate minimal HTML shell\n(app-shell-html my-app)\n;; => <!doctype html>...<div id=\"sx-app-root\">...</div>...\n\n;; Client: boot the app into the root element\n(app-boot my-app (dom-query \"#sx-app-root\"))\n\n;; Client: navigate between routes\n(app-navigate! my-app \"/gallery\")"
"lisp")))
(~docs/section :title "What the Server Returns" :id "app-shell"
(~docs/code :src (highlight
"<!doctype html>\n<html>\n<head>\n <link rel=\"stylesheet\" href=\"/static/app.css\">\n <script src=\"/static/scripts/sx-browser.js\"></script>\n</head>\n<body>\n <div id=\"sx-app-root\"></div>\n <script>\n SX.boot(function(sx) {\n sx.appBoot(/* app config */)\n })\n </script>\n</body>\n</html>"
"html"))
(p "The shell contains no rendered content — just the mount point and loader. "
"Uses existing " (code "sx-mount") " and " (code "sx-hydrate-islands") " from boot.sx.")
(p "~330 lines. Orchestrates all other layers."))))
;; ===========================================================================
;; Live demo islands
;; ===========================================================================
;; ---------------------------------------------------------------------------
;; L0: Ref — timer ID in closure, DOM handle in dict
;; ---------------------------------------------------------------------------
(defisland ~reactive-runtime/demo-ref ()
(let ((input-ref (dict "current" nil))
(ticks (signal 0))
(running (signal false))
(last-value (signal "")))
(let ((_eff (effect (fn ()
(when (deref running)
(let ((id (set-interval (fn () (swap! ticks inc)) 500)))
(fn () (clear-interval id))))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(p :class "text-xs font-semibold text-stone-500 mb-2"
"Timer — interval ID captured in effect closure")
(div :class "flex items-center gap-3 mb-4"
(span :class "text-2xl font-bold text-violet-900 font-mono w-16 text-center"
(deref ticks))
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (swap! running not))
(if (deref running) "Stop" "Start"))
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
:on-click (fn (e) (do (reset! running false) (reset! ticks 0)))
"Reset"))
(p :class "text-xs font-semibold text-stone-500 mb-2"
"DOM handle — element ref in dict")
(div :class "flex items-center gap-3"
(input :ref input-ref :type "text" :placeholder "Type something…"
:class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48")
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
:on-click (fn (e)
(reset! last-value (dom-get-prop (get input-ref "current") "value")))
"Read")
(when (not (= (deref last-value) ""))
(span :class "text-sm text-stone-600 font-mono"
"\"" (deref last-value) "\"")))))))
;; ---------------------------------------------------------------------------
;; L1: Foreign — canvas drawing via host-call
;; ---------------------------------------------------------------------------
(defisland ~reactive-runtime/demo-foreign ()
(let ((canvas-ref (dict "current" nil))
(color (signal "#8b5cf6"))
(count (signal 0)))
(let ((_eff (effect (fn ()
(let ((el (get canvas-ref "current"))
(n (deref count))
(c (deref color)))
(when el
(let ((ctx (host-call el "getContext" "2d")))
(host-call ctx "clearRect" 0 0 280 160)
(host-set! ctx "fillStyle" c)
(host-call ctx "fillRect" 10 10 (min (* n 25) 260) (min (* n 18) 140)))))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(canvas :ref canvas-ref :width "280" :height "160"
:class "border border-stone-300 rounded bg-white block mb-3")
(div :class "flex items-center gap-3"
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (swap! count inc))
"Add")
(select :bind color
:class "px-2 py-1 rounded border border-stone-300 text-sm"
(option :value "#8b5cf6" "Violet")
(option :value "#3b82f6" "Blue")
(option :value "#ef4444" "Red")
(option :value "#22c55e" "Green"))
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
:on-click (fn (e) (reset! count 0))
"Clear")
(span :class "text-sm text-stone-500"
(deref count) " squares"))))))
;; ---------------------------------------------------------------------------
;; L2: State Machine — traffic light
;; ---------------------------------------------------------------------------
(defisland ~reactive-runtime/demo-machine ()
(let ((state (signal "red"))
(transitions (dict "red" "green" "green" "yellow" "yellow" "red"))
(auto (signal false)))
(let ((_eff (effect (fn ()
(when (deref auto)
(let ((delay (if (= (deref state) "yellow") 1000 2500)))
(let ((id (set-timeout
(fn () (reset! state (get transitions (deref state))))
delay)))
(fn () (clear-timeout id)))))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(div :class "flex items-center gap-6"
;; Traffic light
(div :class "flex flex-col gap-2 p-3 bg-stone-800 rounded-lg"
(div :class (str "w-10 h-10 rounded-full transition-colors "
(if (= (deref state) "red") "bg-red-500 shadow-lg shadow-red-500/50" "bg-red-900/30")))
(div :class (str "w-10 h-10 rounded-full transition-colors "
(if (= (deref state) "yellow") "bg-yellow-400 shadow-lg shadow-yellow-400/50" "bg-yellow-900/30")))
(div :class (str "w-10 h-10 rounded-full transition-colors "
(if (= (deref state) "green") "bg-green-500 shadow-lg shadow-green-500/50" "bg-green-900/30"))))
;; Controls
(div :class "space-y-3"
(div :class "flex items-center gap-2"
(span :class "text-sm font-medium text-stone-700" "State:")
(span :class "text-sm font-mono text-violet-700" (deref state)))
(div :class "flex items-center gap-2"
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e)
(reset! state (get transitions (deref state))))
"Next")
(button :class (str "px-3 py-1 rounded text-sm font-medium "
(if (deref auto)
"bg-amber-500 text-white hover:bg-amber-600"
"bg-stone-300 text-stone-700 hover:bg-stone-400"))
:on-click (fn (e) (swap! auto not))
(if (deref auto) "Auto: ON" "Auto: OFF")))))))))
;; ---------------------------------------------------------------------------
;; L3: Commands — counter with undo/redo
;; ---------------------------------------------------------------------------
(defisland ~reactive-runtime/demo-commands ()
(let ((value (signal 0))
(history (signal (list)))
(future (signal (list)))
(undo-class (signal "bg-stone-200 text-stone-400 cursor-not-allowed"))
(redo-class (signal "bg-stone-200 text-stone-400 cursor-not-allowed")))
(let ((do-cmd (fn (new-val)
(let ((old-val (deref value))
(old-hist (deref history)))
(do
(reset! history (cons old-val old-hist))
(reset! future (list))
(reset! value new-val)))))
(undo (fn ()
(let ((h (deref history)))
(when (> (len h) 0)
(let ((prev (first h))
(old-val (deref value))
(f (deref future)))
(do
(reset! future (cons old-val f))
(reset! history (rest h))
(reset! value prev)))))))
(redo (fn ()
(let ((f (deref future)))
(when (> (len f) 0)
(let ((next-val (first f))
(old-val (deref value))
(h (deref history)))
(do
(reset! history (cons old-val h))
(reset! future (rest f))
(reset! value next-val)))))))
(_e1 (effect (fn ()
(reset! undo-class
(if (> (len (deref history)) 0)
"bg-stone-600 text-white hover:bg-stone-700"
"bg-stone-200 text-stone-400 cursor-not-allowed")))))
(_e2 (effect (fn ()
(reset! redo-class
(if (> (len (deref future)) 0)
"bg-stone-600 text-white hover:bg-stone-700"
"bg-stone-200 text-stone-400 cursor-not-allowed"))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(div :class "flex items-center gap-3 mb-3"
(span :class "text-3xl font-bold text-violet-900 font-mono w-20 text-center"
(deref value))
(div :class "flex gap-1"
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (do-cmd (+ (deref value) 1)))
"+1")
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (do-cmd (+ (deref value) 5)))
"+5")
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (do-cmd (* (deref value) 2)))
"×2")
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (do-cmd 0))
"0")))
(div :class "flex items-center gap-3"
(button :class (str "px-3 py-1 rounded text-sm font-medium " (deref undo-class))
:on-click (fn (e) (undo))
"Undo")
(button :class (str "px-3 py-1 rounded text-sm font-medium " (deref redo-class))
:on-click (fn (e) (redo))
"Redo")
(span :class "text-xs text-stone-400 font-mono"
"history: " (deref (computed (fn () (len (deref history)))))))))))
;; ---------------------------------------------------------------------------
;; L4: Render Loop — bouncing ball
;; ---------------------------------------------------------------------------
(defisland ~reactive-runtime/demo-loop ()
(let ((running (signal true))
(x (signal 0))
(dir (signal 1))
(frames (signal 0)))
(let ((_eff (effect (fn ()
(when (deref running)
(let ((id (set-interval
(fn ()
(batch (fn ()
(swap! frames inc)
(when (>= (deref x) 230) (reset! dir -1))
(when (<= (deref x) 0) (reset! dir 1))
(swap! x (fn (v) (+ v (* (deref dir) 3)))))))
16)))
(fn () (clear-interval id))))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(div :class "relative h-12 bg-white rounded border border-stone-200 mb-3 overflow-hidden"
(div :class "absolute top-1 w-10 h-10 rounded-full bg-violet-500 transition-none"
:style (str "left:" (deref x) "px")))
(div :class "flex items-center gap-3"
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (swap! running not))
(if (deref running) "Pause" "Play"))
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
:on-click (fn (e)
(batch (fn ()
(reset! running false)
(reset! x 0)
(reset! dir 1)
(reset! frames 0))))
"Reset")
(span :class "text-sm text-stone-500 font-mono"
"frames: " (deref frames)))))))
;; ---------------------------------------------------------------------------
;; L5: Keyed Lists — reorderable items
;; ---------------------------------------------------------------------------
(defisland ~reactive-runtime/demo-keyed-lists ()
(let ((next-id (signal 1))
(items (signal (list)))
(colors (list "violet" "blue" "green" "amber" "red" "stone"))
(add-item (fn (e)
(let ((id (deref next-id))
(old (deref items)))
(do
(reset! items (append old (dict "id" id
"text" (str "Item " id)
"color" (nth colors (mod (- id 1) 6)))))
(reset! next-id (+ id 1))))))
(remove-item (fn (id)
(reset! items (filter (fn (item) (not (= (get item "id") id))) (deref items))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(div :class "flex items-center gap-2 mb-3"
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click add-item
"Add Item")
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (reset! items (reverse (deref items))))
"Reverse")
(span :class "text-sm text-stone-500"
(deref (computed (fn () (len (deref items))))) " items"))
(ul :class "space-y-1"
(map (fn (item)
(li :key (str (get item "id"))
:class (str "flex items-center justify-between rounded px-3 py-2 text-sm bg-" (get item "color") "-100 text-" (get item "color") "-800")
(span (get item "text"))
(button :class "text-stone-400 hover:text-red-500 text-xs ml-2"
:on-click (fn (e) (remove-item (get item "id")))
"✕")))
(deref items))))))
;; ---------------------------------------------------------------------------
;; L6: App Shell — client-side mini-app
;; ---------------------------------------------------------------------------
(defisland ~reactive-runtime/demo-app-shell ()
(let ((route (signal "home"))
(count (signal 0)))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
;; Client-side nav bar
(nav :class "flex gap-1 border-b border-stone-200 pb-2 mb-3"
(button :class (str "px-2 py-1 text-xs rounded "
(if (= (deref route) "home")
"bg-violet-600 text-white font-medium"
"bg-stone-200 text-stone-600 hover:bg-stone-300"))
:on-click (fn (e) (reset! route "home"))
"Home")
(button :class (str "px-2 py-1 text-xs rounded "
(if (= (deref route) "counter")
"bg-violet-600 text-white font-medium"
"bg-stone-200 text-stone-600 hover:bg-stone-300"))
:on-click (fn (e) (reset! route "counter"))
"Counter")
(button :class (str "px-2 py-1 text-xs rounded "
(if (= (deref route) "about")
"bg-violet-600 text-white font-medium"
"bg-stone-200 text-stone-600 hover:bg-stone-300"))
:on-click (fn (e) (reset! route "about"))
"About"))
;; Reactive view switching
(div :class "min-h-24"
(if (= (deref route) "home")
(div :class "p-3"
(h3 :class "font-bold text-stone-800 mb-1" "Home")
(p :class "text-sm text-stone-600"
"Client-side rendered. No server round-trip for navigation."))
(if (= (deref route) "counter")
(div :class "p-3"
(h3 :class "font-bold text-stone-800 mb-2" "Counter")
(div :class "flex items-center gap-3"
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (swap! count dec))
"")
(span :class "text-2xl font-bold text-violet-900 font-mono w-12 text-center"
(deref count))
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (swap! count inc))
"+"))
(p :class "text-xs text-stone-400 mt-2"
"State persists across view switches — signals live in closures."))
(div :class "p-3"
(h3 :class "font-bold text-stone-800 mb-1" "About")
(p :class "text-sm text-stone-600"
"This mini-app is entirely client-rendered. The server provides only "
"the component definition and mount point — zero HTML content."))))))))