diff --git a/hosts/javascript/platform.py b/hosts/javascript/platform.py
index d35467c..92fb08d 100644
--- a/hosts/javascript/platform.py
+++ b/hosts/javascript/platform.py
@@ -2047,8 +2047,10 @@ PLATFORM_DOM_JS = """
: function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } finally { runPostRenderHooks(); } })
: handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
- el.addEventListener(name, wrapped);
- return function() { el.removeEventListener(name, wrapped); };
+ var passiveEvents = { touchstart: 1, touchmove: 1, wheel: 1, scroll: 1 };
+ var opts = passiveEvents[name] ? { passive: true } : undefined;
+ el.addEventListener(name, wrapped, opts);
+ return function() { el.removeEventListener(name, wrapped, opts); };
}
function eventDetail(e) {
@@ -2670,6 +2672,7 @@ PLATFORM_ORCHESTRATION_JS = """
function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); }
function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); }
+ function eventModifierKey_p(e) { return !!(e && (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)); }
function domFocus(el) { if (el && el.focus) el.focus(); }
function tryCatch(tryFn, catchFn) {
var t = _wrapSxFn(tryFn);
@@ -2773,6 +2776,7 @@ PLATFORM_ORCHESTRATION_JS = """
function bindBoostLink(el, _href) {
el.addEventListener("click", function(e) {
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
e.preventDefault();
// Re-read href from element at click time (not closed-over value)
var liveHref = el.getAttribute("href") || _href;
@@ -2804,6 +2808,7 @@ PLATFORM_ORCHESTRATION_JS = """
function bindClientRouteClick(link, _href, fallbackFn) {
link.addEventListener("click", function(e) {
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
e.preventDefault();
// Re-read href from element at click time (not closed-over value)
var liveHref = link.getAttribute("href") || _href;
@@ -2954,7 +2959,7 @@ PLATFORM_ORCHESTRATION_JS = """
} else {
fn();
}
- });
+ }, { passive: true });
});
}
@@ -3283,6 +3288,7 @@ def fixups_js(has_html, has_sx, has_dom, has_signals=False, has_deps=False, has_
if (typeof domClosest === "function") PRIMITIVES["dom-closest"] = domClosest;
if (typeof domMatches === "function") PRIMITIVES["dom-matches?"] = domMatches;
if (typeof preventDefault_ === "function") PRIMITIVES["prevent-default"] = preventDefault_;
+ if (typeof eventModifierKey_p === "function") PRIMITIVES["event-modifier-key?"] = eventModifierKey_p;
if (typeof elementValue === "function") PRIMITIVES["element-value"] = elementValue;
if (typeof domOuterHtml === "function") PRIMITIVES["dom-outer-html"] = domOuterHtml;
if (typeof domInnerHtml === "function") PRIMITIVES["dom-inner-html"] = domInnerHtml;
diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml
index be38e02..6690edc 100644
--- a/hosts/ocaml/bin/sx_server.ml
+++ b/hosts/ocaml/bin/sx_server.ml
@@ -125,11 +125,9 @@ let io_batch_mode = ref false
let io_queue : (int * string * value list) list ref = ref []
let io_counter = ref 0
-(** Module-level scope stacks — shared between make_server_env (aser
- scope-push!/pop!) and step-sf-context (via get-primitive "scope-peek"). *)
-(** Request cookies — set by Python bridge before each page render.
- get-cookie reads from here on the server; set-cookie is a no-op
- (server can't set response cookies from SX — that's the framework's job). *)
+(* Request cookies — set by Python bridge before each page render.
+ get-cookie reads from here on the server; set-cookie is a no-op
+ (server can't set response cookies from SX — that's the framework's job). *)
let _request_cookies : (string, string) Hashtbl.t = Hashtbl.create 8
let () = Sx_primitives.register "get-cookie" (fun args ->
@@ -144,6 +142,8 @@ let () = Sx_primitives.register "set-cookie" (fun _args ->
(* No-op on server — cookies are set via HTTP response headers *)
Nil)
+(* Module-level scope stacks — shared between make_server_env (aser
+ scope-push!/pop!) and step-sf-context (via get-primitive "scope-peek"). *)
let _scope_stacks : (string, value list) Hashtbl.t = Hashtbl.create 8
let () = Sx_primitives.register "scope-push!" (fun args ->
@@ -496,6 +496,10 @@ let make_server_env () =
bind "dom-text-content" (fun _args -> String "");
bind "dom-set-text-content" (fun _args -> Nil);
bind "dom-body" (fun _args -> Nil);
+ bind "dom-create-element" (fun _args -> Nil);
+ bind "dom-append" (fun _args -> Nil);
+ bind "create-text-node" (fun _args -> Nil);
+ bind "render-to-dom" (fun _args -> Nil);
(* Raw HTML — platform primitives for adapter-html.sx *)
bind "make-raw-html" (fun args ->
@@ -617,6 +621,16 @@ let make_server_env () =
Sx_ref.eval_expr m.m_body (Env body_env)
| _ -> raise (Eval_error "expand-macro: expected (macro args env)"));
+ (* Expose register-special-form! and *custom-special-forms* to SX code
+ (used by web-forms.sx and adapter form-classification functions) *)
+ bind "register-special-form!" (fun args ->
+ match args with
+ | [String name; handler] ->
+ ignore (Sx_ref.register_special_form (String name) handler);
+ Nil
+ | _ -> raise (Eval_error "register-special-form!: expected (name handler)"));
+ ignore (env_bind env "*custom-special-forms*" Sx_ref.custom_special_forms);
+
(* Register <> as a special form — evaluates all children, returns list *)
ignore (Sx_ref.register_special_form (String "<>") (NativeFn ("<>", fun args ->
List (List.map (fun a -> Sx_ref.eval_expr a (Env env)) args))));
@@ -725,10 +739,8 @@ let make_server_env () =
bind "set-response-status" (fun args -> io_request "set-response-status" args);
bind "set-response-header" (fun args -> io_request "set-response-header" args);
- (* Application constructs — no-ops in the kernel, but needed so
- handler/page files load successfully (their define forms get evaluated) *)
- ignore (Sx_ref.register_special_form (String "defhandler") (NativeFn ("defhandler", fun _args -> Nil)));
- ignore (Sx_ref.register_special_form (String "defpage") (NativeFn ("defpage", fun _args -> Nil)));
+ (* defhandler/defpage/defquery/defaction/defrelation are registered by
+ web-forms.sx via register-special-form!, no longer hardcoded here. *)
bind "cond-scheme?" (fun args ->
match args with
@@ -1504,6 +1516,7 @@ let cli_mode mode =
Filename.concat base "render.sx";
Filename.concat web_base "adapter-html.sx";
Filename.concat web_base "adapter-sx.sx";
+ Filename.concat web_base "web-forms.sx";
] in
(* Load spec files for all CLI modes that need rendering *)
(if mode = "aser" || mode = "aser-slot" || mode = "render" then
@@ -1588,6 +1601,7 @@ let test_mode () =
Filename.concat web_base "signals.sx";
Filename.concat web_base "adapter-html.sx";
Filename.concat web_base "adapter-sx.sx";
+ Filename.concat web_base "web-forms.sx";
] in
cli_load_files env files;
(* Register JIT *)
diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js
index 5928da0..bd29fbf 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-24T04:23:51Z";
+ var SX_VERSION = "2026-03-24T09:53:22Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -2237,7 +2237,7 @@ PRIMITIVES["VOID_ELEMENTS"] = VOID_ELEMENTS;
PRIMITIVES["BOOLEAN_ATTRS"] = BOOLEAN_ATTRS;
// definition-form?
- var isDefinitionForm = function(name) { return sxOr((name == "define"), (name == "defcomp"), (name == "defisland"), (name == "defmacro"), (name == "defstyle"), (name == "defhandler"), (name == "deftype"), (name == "defeffect")); };
+ var isDefinitionForm = function(name) { return sxOr((name == "define"), (name == "defcomp"), (name == "defisland"), (name == "defmacro"), (name == "defstyle"), (name == "deftype"), (name == "defeffect")); };
PRIMITIVES["definition-form?"] = isDefinitionForm;
// parse-element-args
@@ -2520,7 +2520,7 @@ PRIMITIVES["render-to-html"] = renderToHtml;
PRIMITIVES["render-value-to-html"] = renderValueToHtml;
// RENDER_HTML_FORMS
- var RENDER_HTML_FORMS = ["if", "when", "cond", "case", "let", "let*", "letrec", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "deftype", "defeffect", "map", "map-indexed", "filter", "for-each", "scope", "provide"];
+ var RENDER_HTML_FORMS = ["if", "when", "cond", "case", "let", "let*", "letrec", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "deftype", "defeffect", "map", "map-indexed", "filter", "for-each", "scope", "provide"];
PRIMITIVES["RENDER_HTML_FORMS"] = RENDER_HTML_FORMS;
// render-html-form?
@@ -2533,10 +2533,10 @@ PRIMITIVES["render-html-form?"] = isRenderHtmlForm;
return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() {
var name = symbolName(head);
var args = rest(expr);
- return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy((name == "lake")) ? renderHtmlLake(args, env) : (isSxTruthy((name == "marsh")) ? renderHtmlMarsh(args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
+ return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy((name == "lake")) ? renderHtmlLake(args, env) : (isSxTruthy((name == "marsh")) ? renderHtmlMarsh(args, env) : (isSxTruthy(sxOr((name == "portal"), (name == "error-boundary"), (name == "promise-delayed"))) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
var val = envGet(env, name);
return (isSxTruthy(isComponent(val)) ? renderHtmlComponent(val, args, env) : (isSxTruthy(isMacro(val)) ? renderToHtml(expandMacro(val, args, env), env) : error((String("Unknown component: ") + String(name)))));
-})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))))));
+})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))));
})());
})()); };
PRIMITIVES["render-list-to-html"] = renderListToHtml;
@@ -3110,7 +3110,7 @@ PRIMITIVES["render-dom-raw"] = renderDomRaw;
PRIMITIVES["render-dom-unknown-component"] = renderDomUnknownComponent;
// RENDER_DOM_FORMS
- var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal", "error-boundary", "scope", "provide"];
+ var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "map", "map-indexed", "filter", "for-each", "portal", "error-boundary", "scope", "provide"];
PRIMITIVES["RENDER_DOM_FORMS"] = RENDER_DOM_FORMS;
// render-dom-form?
@@ -4181,7 +4181,7 @@ PRIMITIVES["bind-triggers"] = bindTriggers;
return (isSxTruthy((val == lastVal)) ? (shouldFire = false) : (lastVal = val));
})();
}
- return (isSxTruthy(shouldFire) ? ((isSxTruthy(sxOr((eventName == "submit"), (isSxTruthy((eventName == "click")) && domHasAttr(el, "href")))) ? preventDefault_(e) : NIL), (function() {
+ return (isSxTruthy(shouldFire) ? ((isSxTruthy((isSxTruthy((eventName == "click")) && eventModifierKey_p(e))) ? (shouldFire = false) : NIL), (isSxTruthy(shouldFire) ? ((isSxTruthy(sxOr((eventName == "submit"), (isSxTruthy((eventName == "click")) && domHasAttr(el, "href")))) ? preventDefault_(e) : NIL), (function() {
var liveInfo = sxOr(getVerbInfo(el), verbInfo);
var isGetLink = (isSxTruthy((eventName == "click")) && isSxTruthy((get(liveInfo, "method") == "GET")) && isSxTruthy(domHasAttr(el, "href")) && !isSxTruthy(get(mods, "delay")));
var clientRouted = false;
@@ -4189,7 +4189,7 @@ PRIMITIVES["bind-triggers"] = bindTriggers;
clientRouted = tryClientRoute(urlPathname(get(liveInfo, "url")), domGetAttr(el, "sx-target"));
}
return (isSxTruthy(clientRouted) ? (browserPushState(get(liveInfo, "url")), browserScrollTo(0, 0)) : ((isSxTruthy(isGetLink) ? logInfo((String("sx:route server fetch ") + String(get(liveInfo, "url")))) : NIL), (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(function() { return executeRequest(el, NIL, NIL); }, get(mods, "delay")))) : executeRequest(el, NIL, NIL))));
-})()) : NIL);
+})()) : NIL)) : NIL);
})(); }, (isSxTruthy(get(mods, "once")) ? {["once"]: true} : NIL)) : NIL);
})(); };
PRIMITIVES["bind-event"] = bindEvent;
@@ -5880,6 +5880,7 @@ PRIMITIVES["resource"] = resource;
if (typeof domClosest === "function") PRIMITIVES["dom-closest"] = domClosest;
if (typeof domMatches === "function") PRIMITIVES["dom-matches?"] = domMatches;
if (typeof preventDefault_ === "function") PRIMITIVES["prevent-default"] = preventDefault_;
+ if (typeof eventModifierKey_p === "function") PRIMITIVES["event-modifier-key?"] = eventModifierKey_p;
if (typeof elementValue === "function") PRIMITIVES["element-value"] = elementValue;
if (typeof domOuterHtml === "function") PRIMITIVES["dom-outer-html"] = domOuterHtml;
if (typeof domInnerHtml === "function") PRIMITIVES["dom-inner-html"] = domInnerHtml;
@@ -6183,8 +6184,10 @@ PRIMITIVES["resource"] = resource;
: function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } finally { runPostRenderHooks(); } })
: handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
- el.addEventListener(name, wrapped);
- return function() { el.removeEventListener(name, wrapped); };
+ var passiveEvents = { touchstart: 1, touchmove: 1, wheel: 1, scroll: 1 };
+ var opts = passiveEvents[name] ? { passive: true } : undefined;
+ el.addEventListener(name, wrapped, opts);
+ return function() { el.removeEventListener(name, wrapped, opts); };
}
function eventDetail(e) {
@@ -6802,6 +6805,7 @@ PRIMITIVES["resource"] = resource;
function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); }
function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); }
+ function eventModifierKey_p(e) { return !!(e && (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)); }
function domFocus(el) { if (el && el.focus) el.focus(); }
function tryCatch(tryFn, catchFn) {
var t = _wrapSxFn(tryFn);
@@ -6905,6 +6909,7 @@ PRIMITIVES["resource"] = resource;
function bindBoostLink(el, _href) {
el.addEventListener("click", function(e) {
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
e.preventDefault();
// Re-read href from element at click time (not closed-over value)
var liveHref = el.getAttribute("href") || _href;
@@ -6936,6 +6941,7 @@ PRIMITIVES["resource"] = resource;
function bindClientRouteClick(link, _href, fallbackFn) {
link.addEventListener("click", function(e) {
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
e.preventDefault();
// Re-read href from element at click time (not closed-over value)
var liveHref = link.getAttribute("href") || _href;
@@ -7086,7 +7092,7 @@ PRIMITIVES["resource"] = resource;
} else {
fn();
}
- });
+ }, { passive: true });
});
}
diff --git a/shared/sx/ocaml_bridge.py b/shared/sx/ocaml_bridge.py
index 85b56cf..fc3b747 100644
--- a/shared/sx/ocaml_bridge.py
+++ b/shared/sx/ocaml_bridge.py
@@ -420,10 +420,11 @@ class OcamlBridge:
# All directories loaded into the Python env
all_dirs = list(set(_watched_dirs) | _dirs_from_cache)
- # Isomorphic libraries: signals, rendering, freeze scopes
+ # Isomorphic libraries: signals, rendering, freeze scopes, web forms
web_dir = os.path.join(os.path.dirname(__file__), "../../web")
if os.path.isdir(web_dir):
- for web_file in ["signals.sx", "adapter-html.sx", "adapter-sx.sx"]:
+ for web_file in ["signals.sx", "adapter-html.sx", "adapter-sx.sx",
+ "web-forms.sx"]:
path = os.path.normpath(os.path.join(web_dir, web_file))
if os.path.isfile(path):
all_files.append(path)
diff --git a/shared/sx/templates/cssx.sx b/shared/sx/templates/cssx.sx
index 3200b2b..6dc8df5 100644
--- a/shared/sx/templates/cssx.sx
+++ b/shared/sx/templates/cssx.sx
@@ -494,16 +494,13 @@
;; =========================================================================
(defcomp ~cssx/flush () :affinity :client
- (let ((rules (collected "cssx")))
- (clear-collected! "cssx")
- (when (not (empty? rules))
- ;; Append to the persistent ")))))))
+ (let ((rules (collected "cssx"))
+ (head-style (dom-query "#sx-css")))
+ ;; On client: append rules to