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>
703 lines
47 KiB
Plaintext
703 lines
47 KiB
Plaintext
;; 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."))))))))
|