diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 137bf26..09f9442 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-14T10:44:25Z"; + var SX_VERSION = "2026-03-14T14:12:59Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -6547,6 +6547,21 @@ return (function() { return cekValue(state); }; + // CEK stepping primitives — for debugger islands + PRIMITIVES["make-cek-state"] = makeCekState; + PRIMITIVES["cek-step"] = cekStep; + PRIMITIVES["cek-terminal?"] = cekTerminal_p; + PRIMITIVES["cek-value"] = cekValue; + PRIMITIVES["make-env"] = function() { return merge(PRIMITIVES); }; + PRIMITIVES["sx-serialize"] = sxSerialize; + PRIMITIVES["lambda-name"] = lambdaName; + PRIMITIVES["callable?"] = isCallable; + PRIMITIVES["is-html-tag?"] = function(name) { return HTML_TAGS.indexOf(name) >= 0; }; + PRIMITIVES["make-symbol"] = function(name) { return new Symbol(name); }; + PRIMITIVES["keyword-name"] = keywordName; + PRIMITIVES["type-of"] = typeOf; + PRIMITIVES["symbol-name"] = symbolName; + // ========================================================================= // Async IO: Promise-aware rendering for client-side IO primitives diff --git a/shared/sx/ref/platform_js.py b/shared/sx/ref/platform_js.py index 218418b..2d08183 100644 --- a/shared/sx/ref/platform_js.py +++ b/shared/sx/ref/platform_js.py @@ -1508,6 +1508,21 @@ CEK_FIXUPS_JS = ''' while (!cekTerminal_p(state)) { state = cekStep(state); } return cekValue(state); }; + + // CEK stepping primitives — for debugger islands + PRIMITIVES["make-cek-state"] = makeCekState; + PRIMITIVES["cek-step"] = cekStep; + PRIMITIVES["cek-terminal?"] = cekTerminal_p; + PRIMITIVES["cek-value"] = cekValue; + PRIMITIVES["make-env"] = function() { return merge(PRIMITIVES); }; + PRIMITIVES["sx-serialize"] = sxSerialize; + PRIMITIVES["lambda-name"] = lambdaName; + PRIMITIVES["callable?"] = isCallable; + PRIMITIVES["is-html-tag?"] = function(name) { return HTML_TAGS.indexOf(name) >= 0; }; + PRIMITIVES["make-symbol"] = function(name) { return new Symbol(name); }; + PRIMITIVES["keyword-name"] = keywordName; + PRIMITIVES["type-of"] = typeOf; + PRIMITIVES["symbol-name"] = symbolName; ''' diff --git a/sx/sx/geography/cek.sx b/sx/sx/geography/cek.sx index a723d6a..d0b62fc 100644 --- a/sx/sx/geography/cek.sx +++ b/sx/sx/geography/cek.sx @@ -148,6 +148,297 @@ "lisp"))))) +;; --------------------------------------------------------------------------- +;; CEK stepper: interactive stepping debugger +;; --------------------------------------------------------------------------- + +(defisland ~geography/cek/demo-stepper (&key initial-expr) + (let ((source (signal (or initial-expr "(+ 1 (* 2 3))"))) + (state (signal nil)) + (steps (signal 0)) + (history (signal (list))) + (error-msg (signal nil))) + + ;; Parse and create initial CEK state + (define start-eval + (fn () + (reset! error-msg nil) + (reset! history (list)) + (reset! steps 0) + (let ((parsed (sx-parse (deref source)))) + (if (empty? parsed) + (reset! error-msg "Parse error: empty expression") + (reset! state (make-cek-state (first parsed) (make-env) (list))))))) + + ;; Single step + (define do-step + (fn () + (when (and (deref state) (not (cek-terminal? (deref state)))) + (let ((prev (deref state))) + (swap! history (fn (h) (append h (list prev)))) + (swap! steps inc) + (reset! state (cek-step prev)))))) + + ;; Run to completion + (define do-run + (fn () + (when (deref state) + (let run-loop ((n 0)) + (when (and (not (cek-terminal? (deref state))) (< n 200)) + (do-step) + (run-loop (+ n 1))))))) + + ;; Reset + (define do-reset + (fn () + (reset! state nil) + (reset! steps 0) + (reset! history (list)) + (reset! error-msg nil))) + + ;; Format control for display + (define fmt-control + (fn (s) + (if (nil? s) "\u2014" + (let ((c (get s "control"))) + (if (nil? c) "\u2014" + (sx-serialize c)))))) + + ;; Format value + (define fmt-value + (fn (s) + (if (nil? s) "\u2014" + (let ((v (get s "value"))) + (cond + (nil? v) "nil" + (callable? v) (str "\u03bb:" (or (lambda-name v) "fn")) + :else (sx-serialize v)))))) + + ;; Format kont + (define fmt-kont + (fn (s) + (if (nil? s) "\u2014" + (let ((k (get s "kont"))) + (if (empty? k) "[]" + (str "[" (join " " (map (fn (f) (get f "type")) k)) "]")))))) + + ;; Initialize on first render + (start-eval) + + (div :class "space-y-4" + ;; Input + (div :class "flex gap-2 items-end" + (div :class "flex-1" + (label :class "text-xs text-stone-400 block mb-1" "Expression") + (input :type "text" :bind source + :class "w-full px-3 py-1.5 rounded border border-stone-300 font-mono text-sm focus:outline-none focus:border-violet-400" + :on-change (fn (e) (start-eval)))) + (div :class "flex gap-1" + (button :on-click (fn (e) (start-eval)) + :class "px-3 py-1.5 rounded bg-stone-200 text-stone-700 text-sm hover:bg-stone-300" "Reset") + (button :on-click (fn (e) (do-step)) + :class "px-3 py-1.5 rounded bg-violet-500 text-white text-sm hover:bg-violet-600" "Step") + (button :on-click (fn (e) (do-run)) + :class "px-3 py-1.5 rounded bg-violet-700 text-white text-sm hover:bg-violet-800" "Run"))) + + ;; Error + (when (deref error-msg) + (div :class "text-red-600 text-sm" (deref error-msg))) + + ;; Current state + (when (deref state) + (div :class "rounded border border-stone-200 bg-white p-3 font-mono text-sm space-y-1" + (div :class "flex gap-4" + (span :class "text-stone-400 w-16" "Step") + (span :class "font-bold" (deref steps))) + (div :class "flex gap-4" + (span :class "text-stone-400 w-16" "Phase") + (span :class (str "font-bold " (if (= (get (deref state) "phase") "eval") "text-blue-600" "text-green-600")) + (get (deref state) "phase"))) + (div :class "flex gap-4" + (span :class "text-violet-500 w-16" "C") + (span (fmt-control (deref state)))) + (div :class "flex gap-4" + (span :class "text-amber-600 w-16" "V") + (span (fmt-value (deref state)))) + (div :class "flex gap-4" + (span :class "text-emerald-600 w-16" "K") + (span (fmt-kont (deref state)))) + (when (cek-terminal? (deref state)) + (div :class "mt-2 pt-2 border-t border-stone-200 text-stone-800 font-bold" + (str "Result: " (sx-serialize (cek-value (deref state)))))))) + + ;; Step history + (when (not (empty? (deref history))) + (div :class "rounded border border-stone-100 bg-stone-50 p-2" + (div :class "text-xs text-stone-400 mb-1" "History") + (div :class "space-y-0.5 font-mono text-xs max-h-48 overflow-y-auto" + (map-indexed (fn (i s) + (div :class "flex gap-2 text-stone-500" + (span :class "text-stone-300 w-6 text-right" (+ i 1)) + (span :class (if (= (get s "phase") "eval") "text-blue-400" "text-green-400") (get s "phase")) + (span :class "text-violet-400 truncate" (fmt-control s)) + (span :class "text-amber-400" (fmt-value s)) + (span :class "text-emerald-400" (fmt-kont s)))) + (deref history)))))))) + + +;; --------------------------------------------------------------------------- +;; Render stepper: watch a component render itself, tag by tag +;; +;; Walks the SX AST depth-first. At each step, renders ONE subtree +;; via render-to-html and appends to the accumulating output. +;; The preview pane shows partial HTML building up. +;; --------------------------------------------------------------------------- + +(defisland ~geography/cek/demo-render-stepper (&key initial-expr) + (let ((source (signal (or initial-expr + "(div :class \"p-4 rounded border border-violet-200 bg-violet-50\"\n (h2 :class \"font-bold text-violet-800\" \"Hello from CEK\")\n (p :class \"text-stone-600\" (str \"2+3 = \" (+ 2 3))))"))) + (queue (signal (list))) + (rendered (signal (list))) + (preview (signal "")) + (current-sx (signal "")) + (done (signal false)) + (error-msg (signal nil)) + (build-queue nil) + (render-attrs nil) + (start-render nil) + (do-step nil) + (do-run nil)) + + ;; Functions assigned via set! inside a non-rendering let binding + (let ((_ (begin + (set! build-queue + (fn (expr) + (if (not (list? expr)) + (list {"type" "leaf" "expr" expr}) + (if (empty? expr) (list) + (let ((head (first expr))) + (if (not (= (type-of head) "symbol")) + (list {"type" "leaf" "expr" expr}) + (let ((name (symbol-name head)) + (args (rest expr))) + (if (is-html-tag? name) + (let ((children (list)) + (attrs (list)) + (in-kw false)) + (for-each (fn (a) + (cond + (= (type-of a) "keyword") (do (set! in-kw true) (append! attrs a)) + in-kw (do (set! in-kw false) (append! attrs a)) + :else (do (set! in-kw false) (append! children a)))) + args) + (let ((result (list {"type" "open" "tag" name "attrs" attrs}))) + (for-each (fn (child) + (for-each (fn (item) (append! result item)) + (build-queue child))) + children) + (append! result {"type" "close" "tag" name}) + result)) + (list {"type" "eval" "expr" expr}))))))))) + + (set! render-attrs + (fn (attrs) + (if (empty? attrs) "" + (join "" (map-indexed + (fn (i a) + (if (= (mod i 2) 0) + (str " " (keyword-name a) "=\"") + (str a "\""))) + attrs))))) + + (set! start-render + (fn () + (reset! error-msg nil) + (reset! rendered (list)) + (reset! preview "") + (reset! current-sx "") + (reset! done false) + (let ((parsed (sx-parse (deref source)))) + (if (empty? parsed) + (reset! error-msg "Parse error") + (reset! queue (build-queue (first parsed))))))) + + (set! do-step + (fn () + (when (and (not (deref done)) (not (empty? (deref queue)))) + (let ((item (first (deref queue)))) + (swap! queue rest) + (let ((item-type (get item "type"))) + (cond + (= item-type "open") + (let ((tag (get item "tag")) + (attr-str (render-attrs (get item "attrs"))) + (open-html (str "<" tag attr-str ">"))) + (reset! current-sx (str "<" tag ">")) + (swap! preview (fn (p) (str p open-html))) + (swap! rendered (fn (r) (append r (list {"sx" (deref current-sx) "html" open-html}))))) + (= item-type "close") + (let ((tag (get item "tag")) + (close-html (str ""))) + (reset! current-sx (str "")) + (swap! preview (fn (p) (str p close-html))) + (swap! rendered (fn (r) (append r (list {"sx" (deref current-sx) "html" close-html}))))) + :else + (let ((expr (get item "expr")) + (sx-str (sx-serialize expr)) + (html (render-to-html expr (make-env)))) + (reset! current-sx sx-str) + (swap! preview (fn (p) (str p html))) + (swap! rendered (fn (r) (append r (list {"sx" sx-str "html" html}))))))) + (when (empty? (deref queue)) + (reset! done true)))))) + + (set! do-run + (fn () + (let run-loop ((n 0)) + (when (and (not (deref done)) (< n 200)) + (do-step) + (run-loop (+ n 1)))))) + + nil))) + + (div :class "space-y-4" + (div + (label :class "text-xs text-stone-400 block mb-1" "Component expression") + (textarea :bind source :rows 4 + :class "w-full px-3 py-2 rounded border border-stone-300 font-mono text-xs focus:outline-none focus:border-violet-400")) + (div :class "flex gap-1" + (button :on-click (fn (e) (start-render)) + :class "px-3 py-1.5 rounded bg-stone-200 text-stone-700 text-sm hover:bg-stone-300" "Parse") + (button :on-click (fn (e) (do-step)) + :class "px-3 py-1.5 rounded bg-violet-500 text-white text-sm hover:bg-violet-600" "Step") + (button :on-click (fn (e) (do-run)) + :class "px-3 py-1.5 rounded bg-violet-700 text-white text-sm hover:bg-violet-800" "Run")) + + (when (deref error-msg) + (div :class "text-red-600 text-sm" (deref error-msg))) + + (div :class "grid grid-cols-1 md:grid-cols-2 gap-4" + (div :class "rounded border border-stone-200 bg-white p-3 min-h-24" + (div :class "text-xs text-stone-400 mb-2" + (str (len (deref rendered)) " render steps" + (if (deref done) " \u2014 complete" ""))) + (when (not (empty? (deref current-sx))) + (div :class "text-xs font-mono text-violet-700 mb-2 px-1 py-0.5 bg-violet-50 rounded" + (str "\u25b6 " (deref current-sx)))) + (div :class "space-y-0.5 font-mono text-xs max-h-64 overflow-y-auto" + (map-indexed (fn (i step) + (div :class "flex gap-2" + (span :class "text-stone-300 w-4 text-right" (+ i 1)) + (span :class "text-violet-500 flex-1 truncate" (get step "sx")) + (span :class "text-stone-400 truncate" + (let ((h (get step "html"))) + (if (> (len h) 40) (str (slice h 0 37) "...") h))))) + (deref rendered)))) + + (div :class "rounded border border-stone-200 bg-white p-3 min-h-24" + (div :class "text-xs text-stone-400 mb-2" "Live preview") + (if (empty? (deref preview)) + (div :class "text-stone-300 text-sm italic" "Click Parse then Step...") + (raw! (deref preview))))))))) + + ;; --------------------------------------------------------------------------- ;; Demo page content ;; --------------------------------------------------------------------------- @@ -157,39 +448,42 @@ (~docs/section :title "What this demonstrates" :id "what" (p "These are " (strong "live islands") " evaluated by the CEK machine. Every " (code "eval-expr") " goes through " (code "cek-run") ". Every " (code "(deref sig)") " in an island creates a reactive DOM binding via continuation frames.") - (p "The CEK machine is defined in " (code "cek.sx") " (160 lines) and " (code "frames.sx") " (100 lines) — pure s-expressions, bootstrapped to both JavaScript and Python.")) + (p "The CEK machine is defined in " (code "cek.sx") " and " (code "frames.sx") " — pure s-expressions, bootstrapped to both JavaScript and Python.")) + + (~docs/section :title "Stepper" :id "stepper" + (p "The CEK machine is pure data\u2192data. Each step takes a state dict and returns a new one. " + "Type an expression, click Step to advance one CEK transition.") + (~geography/cek/demo-stepper :initial-expr "(let ((x 10)) (+ x (* 2 3)))") + (~docs/code :code (highlight (component-source "~geography/cek/demo-stepper") "lisp"))) + + (~docs/section :title "Render stepper" :id "render-stepper" + (p "Watch a component render itself. The CEK evaluates the expression — " + "when it encounters " (code "(div ...)") ", the render adapter produces HTML in one step. " + "Click Run to see the rendered output appear in the preview.") + (~geography/cek/demo-render-stepper) + (~docs/code :code (highlight (component-source "~geography/cek/demo-render-stepper") "lisp"))) (~docs/section :title "1. Counter" :id "demo-counter" (p (code "(deref count)") " in text position creates a reactive text node. " (code "(deref doubled)") " is a computed that updates when count changes.") (~geography/cek/demo-counter :initial 0) - (~docs/code :code (highlight - "(defisland ~demo-counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div\n (button :on-click (fn (e) (swap! count dec)) \"-\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p (str \"doubled: \" (deref doubled))))))" - "lisp"))) + (~docs/code :code (highlight (component-source "~geography/cek/demo-counter") "lisp"))) (~docs/section :title "2. Computed chain" :id "demo-chain" (p "Three levels of computed: base -> doubled -> quadrupled. Change base, all propagate.") (~geography/cek/demo-chain) - (~docs/code :code (highlight - "(let ((base (signal 1))\n (doubled (computed (fn () (* (deref base) 2))))\n (quadrupled (computed (fn () (* (deref doubled) 2)))))\n (span (deref base))\n (p (str \"doubled: \" (deref doubled)\n \" | quadrupled: \" (deref quadrupled))))" - "lisp"))) + (~docs/code :code (highlight (component-source "~geography/cek/demo-chain") "lisp"))) (~docs/section :title "3. Reactive attributes" :id "demo-attr" (p (code "(deref sig)") " in " (code ":class") " position. The CEK evaluates the " (code "str") " expression, and when the signal changes, the continuation re-evaluates and updates the attribute.") (~geography/cek/demo-reactive-attr) - (~docs/code :code (highlight - "(div :class (str \"p-3 rounded font-medium \"\n (if (deref danger)\n \"bg-red-100 text-red-800\"\n \"bg-green-100 text-green-800\"))\n (if (deref danger) \"DANGER\" \"SAFE\"))" - "lisp"))) + (~docs/code :code (highlight (component-source "~geography/cek/demo-reactive-attr") "lisp"))) (~docs/section :title "4. Effect + cleanup" :id "demo-stopwatch" (p "Effects still work through CEK. This stopwatch uses " (code "effect") " with cleanup — toggling the signal clears the interval.") (~geography/cek/demo-stopwatch) - (~docs/code :code (highlight - "(effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))" - "lisp"))) + (~docs/code :code (highlight (component-source "~geography/cek/demo-stopwatch") "lisp"))) (~docs/section :title "5. Batch coalescing" :id "demo-batch" (p "Two signals updated in " (code "batch") " — one notification cycle. Compare render counts between batch and no-batch.") (~geography/cek/demo-batch) - (~docs/code :code (highlight - "(batch (fn ()\n (swap! first-sig inc)\n (swap! second-sig inc)))\n;; One render pass, not two." - "lisp"))))) + (~docs/code :code (highlight (component-source "~geography/cek/demo-batch") "lisp")))))