Reactive island preservation across server-driven morphs
Islands survive hypermedia swaps: morph-node skips hydrated data-sx-island elements when the same island exists in new content. dispose-islands-in skips hydrated islands to prevent premature cleanup. - @client directive: .sx files marked ;; @client send define forms to browser - CSSX client-side: cssxgroup renamed (no hyphen) to avoid isRenderExpr matching it as a custom element — was producing [object HTMLElement] - Island wrappers: div→span to avoid block-in-inline HTML parse breakage - ~sx-header is now a defisland with inline reactive colour cycling - bootstrap_js.py defaults output to shared/static/scripts/sx-browser.js - Deleted stale sx-ref.js (sx-browser.js is the canonical browser build) - Hegelian Synthesis essay: dialectic of hypertext and reactivity - component-source helper handles Island types for docs pretty-printing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-10T10:47:20Z";
|
||||
var SX_VERSION = "2026-03-10T14:05:08Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -629,7 +629,7 @@
|
||||
var callLambda = function(f, args, callerEnv) { return (function() {
|
||||
var params = lambdaParams(f);
|
||||
var local = envMerge(lambdaClosure(f), callerEnv);
|
||||
return (isSxTruthy((len(args) != len(params))) ? error((String(sxOr(lambdaName(f), "lambda")) + String(" expects ") + String(len(params)) + String(" args, got ") + String(len(args)))) : (forEach(function(pair) { return envSet(local, first(pair), nth(pair, 1)); }, zip(params, args)), makeThunk(lambdaBody(f), local)));
|
||||
return (isSxTruthy((len(args) > len(params))) ? error((String(sxOr(lambdaName(f), "lambda")) + String(" expects ") + String(len(params)) + String(" args, got ") + String(len(args)))) : (forEach(function(pair) { return envSet(local, first(pair), nth(pair, 1)); }, zip(params, args)), forEach(function(p) { return envSet(local, p, NIL); }, slice(params, len(args))), makeThunk(lambdaBody(f), local)));
|
||||
})(); };
|
||||
|
||||
// call-component
|
||||
@@ -1336,7 +1336,7 @@ continue; } else { return NIL; } } };
|
||||
return (function() {
|
||||
var bodyHtml = renderToHtml(componentBody(island), local);
|
||||
var stateJson = serializeIslandState(kwargs);
|
||||
return (String("<div data-sx-island=\"") + String(escapeAttr(islandName)) + String("\"") + String((isSxTruthy(stateJson) ? (String(" data-sx-state=\"") + String(escapeAttr(stateJson)) + String("\"")) : "")) + String(">") + String(bodyHtml) + String("</div>"));
|
||||
return (String("<span data-sx-island=\"") + String(escapeAttr(islandName)) + String("\"") + String((isSxTruthy(stateJson) ? (String(" data-sx-state=\"") + String(escapeAttr(stateJson)) + String("\"")) : "")) + String(">") + String(bodyHtml) + String("</span>"));
|
||||
})();
|
||||
})();
|
||||
})(); };
|
||||
@@ -1761,7 +1761,7 @@ return result; }, args);
|
||||
})();
|
||||
}
|
||||
return (function() {
|
||||
var container = domCreateElement("div", NIL);
|
||||
var container = domCreateElement("span", NIL);
|
||||
var disposers = [];
|
||||
domSetAttr(container, "data-sx-island", islandName);
|
||||
return (function() {
|
||||
@@ -2053,7 +2053,7 @@ return (function() {
|
||||
})(); };
|
||||
|
||||
// morph-node
|
||||
var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy(sxOr(!isSxTruthy((domNodeType(oldNode) == domNodeType(newNode))), !isSxTruthy((domNodeName(oldNode) == domNodeName(newNode))))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!isSxTruthy((domTextContent(oldNode) == domTextContent(newNode)))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!isSxTruthy((isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode)))) ? morphChildren(oldNode, newNode) : NIL)) : NIL)))); };
|
||||
var morphNode = function(oldNode, newNode) { return (isSxTruthy(sxOr(domHasAttr(oldNode, "sx-preserve"), domHasAttr(oldNode, "sx-ignore"))) ? NIL : (isSxTruthy((isSxTruthy(domHasAttr(oldNode, "data-sx-island")) && isSxTruthy(isProcessed(oldNode, "island-hydrated")) && isSxTruthy(domHasAttr(newNode, "data-sx-island")) && (domGetAttr(oldNode, "data-sx-island") == domGetAttr(newNode, "data-sx-island")))) ? NIL : (isSxTruthy(sxOr(!isSxTruthy((domNodeType(oldNode) == domNodeType(newNode))), !isSxTruthy((domNodeName(oldNode) == domNodeName(newNode))))) ? domReplaceChild(domParent(oldNode), domClone(newNode), oldNode) : (isSxTruthy(sxOr((domNodeType(oldNode) == 3), (domNodeType(oldNode) == 8))) ? (isSxTruthy(!isSxTruthy((domTextContent(oldNode) == domTextContent(newNode)))) ? domSetTextContent(oldNode, domTextContent(newNode)) : NIL) : (isSxTruthy((domNodeType(oldNode) == 1)) ? (syncAttrs(oldNode, newNode), (isSxTruthy(!isSxTruthy((isSxTruthy(domIsActiveElement(oldNode)) && domIsInputElement(oldNode)))) ? morphChildren(oldNode, newNode) : NIL)) : NIL))))); };
|
||||
|
||||
// sync-attrs
|
||||
var syncAttrs = function(oldEl, newEl) { { var _c = domAttrList(newEl); for (var _i = 0; _i < _c.length; _i++) { var attr = _c[_i]; (function() {
|
||||
@@ -2982,7 +2982,10 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
// dispose-islands-in
|
||||
var disposeIslandsIn = function(root) { return (isSxTruthy(root) ? (function() {
|
||||
var islands = domQueryAll(root, "[data-sx-island]");
|
||||
return (isSxTruthy((isSxTruthy(islands) && !isSxTruthy(isEmpty(islands)))) ? (logInfo((String("disposing ") + String(len(islands)) + String(" island(s)"))), forEach(disposeIsland, islands)) : NIL);
|
||||
return (isSxTruthy((isSxTruthy(islands) && !isSxTruthy(isEmpty(islands)))) ? (function() {
|
||||
var toDispose = filter(function(el) { return !isSxTruthy(isProcessed(el, "island-hydrated")); }, islands);
|
||||
return (isSxTruthy(!isSxTruthy(isEmpty(toDispose))) ? (logInfo((String("disposing ") + String(len(toDispose)) + String(" island(s)"))), forEach(disposeIsland, toDispose)) : NIL);
|
||||
})() : NIL);
|
||||
})() : NIL); };
|
||||
|
||||
// boot-init
|
||||
@@ -3396,8 +3399,9 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
|
||||
if (!_hasDom || !el) return function() {};
|
||||
// Wrap SX lambdas from runtime-evaluated island code into native fns
|
||||
var wrapped = isLambda(handler)
|
||||
? function(e) { invoke(handler, e); }
|
||||
? function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
|
||||
: 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); };
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ def _render_component(comp: Component, args: list, env: dict[str, Any]) -> str:
|
||||
def _render_island(island: Island, args: list, env: dict[str, Any]) -> str:
|
||||
"""Render an island as static HTML with hydration attributes.
|
||||
|
||||
Produces: <div data-sx-island="name" data-sx-state='{"k":"v",...}'>body HTML</div>
|
||||
Produces: <span data-sx-island="name" data-sx-state='{"k":"v",...}'>body HTML</span>
|
||||
The client hydrates this into a reactive island.
|
||||
"""
|
||||
import json as _json
|
||||
@@ -460,12 +460,12 @@ def _render_island(island: Island, args: list, env: dict[str, Any]) -> str:
|
||||
state_json = _escape_attr(_json.dumps(state, separators=(",", ":"))) if state else ""
|
||||
island_name = _escape_attr(island.name)
|
||||
|
||||
parts = [f'<div data-sx-island="{island_name}"']
|
||||
parts = [f'<span data-sx-island="{island_name}"']
|
||||
if state_json:
|
||||
parts.append(f' data-sx-state="{state_json}"')
|
||||
parts.append(">")
|
||||
parts.append(body_html)
|
||||
parts.append("</div>")
|
||||
parts.append("</span>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ _COMPONENT_ENV: dict[str, Any] = {}
|
||||
# client-side localStorage caching.
|
||||
_COMPONENT_HASH: str = ""
|
||||
|
||||
# Raw source of .sx files marked with ;; @client — sent to the browser
|
||||
# alongside component definitions so define forms (functions, data) are
|
||||
# available for client-side evaluation (e.g. cssx colour/spacing functions).
|
||||
_CLIENT_LIBRARY_SOURCES: list[str] = []
|
||||
|
||||
|
||||
def get_component_env() -> dict[str, Any]:
|
||||
"""Return the shared component environment."""
|
||||
@@ -61,7 +66,7 @@ def _compute_component_hash() -> None:
|
||||
"""Recompute _COMPONENT_HASH from all registered Component and Macro definitions."""
|
||||
global _COMPONENT_HASH
|
||||
from .parser import serialize
|
||||
parts = []
|
||||
parts = list(_CLIENT_LIBRARY_SOURCES)
|
||||
for key in sorted(_COMPONENT_ENV):
|
||||
val = _COMPONENT_ENV[key]
|
||||
if isinstance(val, Island):
|
||||
@@ -96,6 +101,8 @@ def load_sx_dir(directory: str) -> None:
|
||||
"""Load all .sx files from a directory and register components.
|
||||
|
||||
Skips boundary.sx — those are parsed separately by the boundary validator.
|
||||
Files starting with ``;; @client`` have their source stored for delivery
|
||||
to the browser (so ``define`` forms are available client-side).
|
||||
"""
|
||||
for filepath in sorted(
|
||||
glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
|
||||
@@ -103,7 +110,17 @@ def load_sx_dir(directory: str) -> None:
|
||||
if os.path.basename(filepath) == "boundary.sx":
|
||||
continue
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
register_components(f.read())
|
||||
source = f.read()
|
||||
if source.lstrip().startswith(";; @client"):
|
||||
# Parse and re-serialize to normalize syntax sugar.
|
||||
# The Python parser accepts ' for quote but the bootstrapped
|
||||
# client parser uses #' — re-serializing emits (quote x).
|
||||
from .parser import parse_all, serialize
|
||||
exprs = parse_all(source)
|
||||
_CLIENT_LIBRARY_SOURCES.append(
|
||||
"\n".join(serialize(e) for e in exprs)
|
||||
)
|
||||
register_components(source)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -150,6 +167,7 @@ def reload_if_changed() -> None:
|
||||
_logger.info("Changed: %s", fp)
|
||||
t0 = time.monotonic()
|
||||
_COMPONENT_ENV.clear()
|
||||
_CLIENT_LIBRARY_SOURCES.clear()
|
||||
# Reload SX libraries first (e.g. z3.sx) so reader macros resolve
|
||||
for cb in _reload_callbacks:
|
||||
cb()
|
||||
@@ -368,9 +386,10 @@ def client_components_tag(*names: str) -> str:
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
|
||||
if not parts:
|
||||
if not parts and not _CLIENT_LIBRARY_SOURCES:
|
||||
return ""
|
||||
source = "\n".join(parts)
|
||||
all_parts = list(_CLIENT_LIBRARY_SOURCES) + parts
|
||||
source = "\n".join(all_parts)
|
||||
return f'<script type="text/sx" data-components>{source}</script>'
|
||||
|
||||
|
||||
@@ -437,10 +456,12 @@ def components_for_page(page_sx: str, service: str | None = None) -> tuple[str,
|
||||
body_sx = serialize(val.body, pretty=True)
|
||||
parts.append(f"(defmacro {val.name} {params_sx} {body_sx})")
|
||||
|
||||
if not parts:
|
||||
if not parts and not _CLIENT_LIBRARY_SOURCES:
|
||||
return "", ""
|
||||
|
||||
source = "\n".join(parts)
|
||||
# Prepend client library sources (define forms) before component defs
|
||||
all_parts = list(_CLIENT_LIBRARY_SOURCES) + parts
|
||||
source = "\n".join(all_parts)
|
||||
digest = hashlib.sha256(source.encode()).hexdigest()[:12]
|
||||
return source, digest
|
||||
|
||||
|
||||
@@ -630,7 +630,7 @@
|
||||
(env-set! local "children" child-frag)))
|
||||
|
||||
;; Create the island container element
|
||||
(let ((container (dom-create-element "div" nil))
|
||||
(let ((container (dom-create-element "span" nil))
|
||||
(disposers (list)))
|
||||
|
||||
;; Mark as island
|
||||
|
||||
@@ -349,13 +349,13 @@
|
||||
(let ((body-html (render-to-html (component-body island) local))
|
||||
(state-json (serialize-island-state kwargs)))
|
||||
;; Wrap in container with hydration attributes
|
||||
(str "<div data-sx-island=\"" (escape-attr island-name) "\""
|
||||
(str "<span data-sx-island=\"" (escape-attr island-name) "\""
|
||||
(if state-json
|
||||
(str " data-sx-state=\"" (escape-attr state-json) "\"")
|
||||
"")
|
||||
">"
|
||||
body-html
|
||||
"</div>"))))))
|
||||
"</span>"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -400,12 +400,19 @@
|
||||
|
||||
(define dispose-islands-in
|
||||
(fn (root)
|
||||
;; Dispose all islands within root before a swap replaces them.
|
||||
;; Dispose islands within root, but SKIP hydrated islands —
|
||||
;; they may be preserved across morphs. Only dispose islands
|
||||
;; that are not currently hydrated (e.g. freshly parsed content
|
||||
;; being discarded) or that have been explicitly detached.
|
||||
(when root
|
||||
(let ((islands (dom-query-all root "[data-sx-island]")))
|
||||
(when (and islands (not (empty? islands)))
|
||||
(log-info (str "disposing " (len islands) " island(s)"))
|
||||
(for-each dispose-island islands))))))
|
||||
(let ((to-dispose (filter
|
||||
(fn (el) (not (is-processed? el "island-hydrated")))
|
||||
islands)))
|
||||
(when (not (empty? to-dispose))
|
||||
(log-info (str "disposing " (len to-dispose) " island(s)"))
|
||||
(for-each dispose-island to-dispose))))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
Bootstrap compiler: reference SX evaluator → JavaScript.
|
||||
|
||||
Reads the .sx reference specification and emits a standalone JavaScript
|
||||
evaluator (sx-ref.js) that can be compared against the hand-written sx.js.
|
||||
evaluator (sx-browser.js) that runs in the browser.
|
||||
|
||||
The compiler translates the restricted SX subset used in eval.sx/render.sx
|
||||
into idiomatic JavaScript. Platform interface functions are emitted as
|
||||
native JS implementations.
|
||||
|
||||
Usage:
|
||||
python bootstrap_js.py > sx-ref.js
|
||||
python bootstrap_js.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -2911,8 +2911,9 @@ PLATFORM_DOM_JS = """
|
||||
if (!_hasDom || !el) return function() {};
|
||||
// Wrap SX lambdas from runtime-evaluated island code into native fns
|
||||
var wrapped = isLambda(handler)
|
||||
? function(e) { invoke(handler, e); }
|
||||
? function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
|
||||
: 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); };
|
||||
}
|
||||
@@ -4365,8 +4366,9 @@ if __name__ == "__main__":
|
||||
help="Comma-separated extensions (continuations). Default: none.")
|
||||
p.add_argument("--spec-modules",
|
||||
help="Comma-separated spec modules (deps). Default: none.")
|
||||
p.add_argument("--output", "-o",
|
||||
help="Output file (default: stdout)")
|
||||
default_output = os.path.join(os.path.dirname(__file__), "..", "..", "static", "scripts", "sx-browser.js")
|
||||
p.add_argument("--output", "-o", default=default_output,
|
||||
help="Output file (default: shared/static/scripts/sx-browser.js)")
|
||||
args = p.parse_args()
|
||||
|
||||
adapters = args.adapters.split(",") if args.adapters else None
|
||||
@@ -4375,13 +4377,10 @@ if __name__ == "__main__":
|
||||
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
|
||||
js = compile_ref_to_js(adapters, modules, extensions, spec_modules)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(js)
|
||||
included = ", ".join(adapters) if adapters else "all"
|
||||
mods = ", ".join(modules) if modules else "all"
|
||||
ext_label = ", ".join(extensions) if extensions else "none"
|
||||
print(f"Wrote {args.output} ({len(js)} bytes, adapters: {included}, modules: {mods}, extensions: {ext_label})",
|
||||
file=sys.stderr)
|
||||
else:
|
||||
print(js)
|
||||
with open(args.output, "w") as f:
|
||||
f.write(js)
|
||||
included = ", ".join(adapters) if adapters else "all"
|
||||
mods = ", ".join(modules) if modules else "all"
|
||||
ext_label = ", ".join(extensions) if extensions else "none"
|
||||
print(f"Wrote {args.output} ({len(js)} bytes, adapters: {included}, modules: {mods}, extensions: {ext_label})",
|
||||
file=sys.stderr)
|
||||
|
||||
@@ -338,6 +338,16 @@
|
||||
(dom-has-attr? old-node "sx-ignore"))
|
||||
nil
|
||||
|
||||
;; Hydrated island → preserve reactive state across server swaps.
|
||||
;; If old and new are the same island (by name), keep the old DOM
|
||||
;; with its live signals, effects, and event listeners intact.
|
||||
(and (dom-has-attr? old-node "data-sx-island")
|
||||
(is-processed? old-node "island-hydrated")
|
||||
(dom-has-attr? new-node "data-sx-island")
|
||||
(= (dom-get-attr old-node "data-sx-island")
|
||||
(dom-get-attr new-node "data-sx-island")))
|
||||
nil
|
||||
|
||||
;; Different node type or tag → replace wholesale
|
||||
(or (not (= (dom-node-type old-node) (dom-node-type new-node)))
|
||||
(not (= (dom-node-name old-node) (dom-node-name new-node))))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
219
shared/sx/templates/cssx.sx
Normal file
219
shared/sx/templates/cssx.sx
Normal file
@@ -0,0 +1,219 @@
|
||||
;; @client — send all define forms to browser for client-side use.
|
||||
;; CSSX — computed CSS from s-expressions.
|
||||
;;
|
||||
;; Generic mechanism: cssx is a macro that groups CSS property declarations.
|
||||
;; The vocabulary (property mappings, value functions) is pluggable — the
|
||||
;; Tailwind-inspired defaults below are just one possible style system.
|
||||
;;
|
||||
;; Usage:
|
||||
;; (cssx (:text (colour "violet" 699) (size "4xl") (weight "bold") (family "mono"))
|
||||
;; (:bg (colour "stone" 50)))
|
||||
;;
|
||||
;; Each group is (:keyword value ...modifiers):
|
||||
;; - keyword maps to a CSS property via cssx-properties dict
|
||||
;; - value is the CSS value for that property
|
||||
;; - modifiers are extra CSS declaration strings, concatenated in
|
||||
;;
|
||||
;; Single group:
|
||||
;; (cssx (:text (colour "violet" 699)))
|
||||
;;
|
||||
;; Modifiers without a colour:
|
||||
;; (cssx (:text nil (size "4xl") (weight "bold")))
|
||||
;;
|
||||
;; Unknown keywords pass through as raw CSS property names:
|
||||
;; (cssx (:outline (colour "red" 500))) → "outline:hsl(0,72%,53%);"
|
||||
;;
|
||||
;; Standalone modifiers work outside cssx too:
|
||||
;; :style (size "4xl")
|
||||
;; :style (str (weight "bold") (family "mono"))
|
||||
|
||||
;; =========================================================================
|
||||
;; Layer 1: Generic mechanism — cssx macro + cssxgroup function
|
||||
;; =========================================================================
|
||||
|
||||
;; Property keyword → CSS property name. Extend this dict for new mappings.
|
||||
(define cssx-properties
|
||||
{"text" "color"
|
||||
"bg" "background-color"
|
||||
"border" "border-color"})
|
||||
|
||||
;; Evaluate one property group: (:text value modifier1 modifier2 ...)
|
||||
;; If value is nil, only modifiers are emitted (no property declaration).
|
||||
;; NOTE: name must NOT contain hyphens — the evaluator's isRenderExpr check
|
||||
;; treats (hyphenated-name :keyword ...) as a custom HTML element.
|
||||
(define cssxgroup
|
||||
(fn (prop value b c d e)
|
||||
(let ((css-prop (or (get cssx-properties prop) prop)))
|
||||
(str (if (nil? value) "" (str css-prop ":" value ";"))
|
||||
(or b "") (or c "") (or d "") (or e "")))))
|
||||
|
||||
;; cssx macro — takes one or more property groups, expands to (str ...).
|
||||
;; (cssx (:text val ...) (:bg val ...))
|
||||
;; → (str (cssxgroup :text val ...) (cssxgroup :bg val ...))
|
||||
(defmacro cssx (&rest groups)
|
||||
`(str ,@(map (fn (g) (cons 'cssxgroup g)) groups)))
|
||||
|
||||
;; =========================================================================
|
||||
;; Layer 2: Value vocabulary — colour, size, weight, family
|
||||
;; These are independent functions. Use inside cssx groups or standalone.
|
||||
;; Replace or extend with any style system.
|
||||
;; =========================================================================
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Colour — compute CSS colour value from name + shade
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(define colour-bases
|
||||
{"violet" {"h" 263 "s" 70}
|
||||
"purple" {"h" 271 "s" 81}
|
||||
"indigo" {"h" 239 "s" 84}
|
||||
"blue" {"h" 217 "s" 91}
|
||||
"sky" {"h" 199 "s" 89}
|
||||
"cyan" {"h" 188 "s" 94}
|
||||
"teal" {"h" 173 "s" 80}
|
||||
"emerald" {"h" 160 "s" 84}
|
||||
"green" {"h" 142 "s" 71}
|
||||
"lime" {"h" 84 "s" 78}
|
||||
"yellow" {"h" 48 "s" 96}
|
||||
"amber" {"h" 38 "s" 92}
|
||||
"orange" {"h" 25 "s" 95}
|
||||
"red" {"h" 0 "s" 72}
|
||||
"rose" {"h" 350 "s" 89}
|
||||
"pink" {"h" 330 "s" 81}
|
||||
"stone" {"h" 25 "s" 6}
|
||||
"slate" {"h" 215 "s" 16}
|
||||
"gray" {"h" 220 "s" 9}
|
||||
"zinc" {"h" 240 "s" 5}
|
||||
"neutral" {"h" 0 "s" 0}})
|
||||
|
||||
(define lerp (fn (a b t) (+ a (* t (- b a)))))
|
||||
|
||||
(define shade-to-lightness
|
||||
(fn (shade)
|
||||
(cond
|
||||
(<= shade 50) (lerp 100 97 (/ shade 50))
|
||||
(<= shade 100) (lerp 97 93 (/ (- shade 50) 50))
|
||||
(<= shade 200) (lerp 93 87 (/ (- shade 100) 100))
|
||||
(<= shade 300) (lerp 87 77 (/ (- shade 200) 100))
|
||||
(<= shade 400) (lerp 77 64 (/ (- shade 300) 100))
|
||||
(<= shade 500) (lerp 64 53 (/ (- shade 400) 100))
|
||||
(<= shade 600) (lerp 53 45 (/ (- shade 500) 100))
|
||||
(<= shade 700) (lerp 45 38 (/ (- shade 600) 100))
|
||||
(<= shade 800) (lerp 38 30 (/ (- shade 700) 100))
|
||||
(<= shade 900) (lerp 30 21 (/ (- shade 800) 100))
|
||||
(<= shade 950) (lerp 21 13 (/ (- shade 900) 50))
|
||||
true 13)))
|
||||
|
||||
(define colour
|
||||
(fn (name shade)
|
||||
(let ((base (get colour-bases name)))
|
||||
(if (nil? base)
|
||||
name
|
||||
(let ((h (get base "h"))
|
||||
(s (get base "s"))
|
||||
(l (shade-to-lightness shade)))
|
||||
(str "hsl(" h "," s "%," (round l) "%)"))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Font sizes — named size → font-size + line-height (Tailwind v3 scale)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(define cssx-sizes
|
||||
{"xs" "font-size:0.75rem;line-height:1rem;"
|
||||
"sm" "font-size:0.875rem;line-height:1.25rem;"
|
||||
"base" "font-size:1rem;line-height:1.5rem;"
|
||||
"lg" "font-size:1.125rem;line-height:1.75rem;"
|
||||
"xl" "font-size:1.25rem;line-height:1.75rem;"
|
||||
"2xl" "font-size:1.5rem;line-height:2rem;"
|
||||
"3xl" "font-size:1.875rem;line-height:2.25rem;"
|
||||
"4xl" "font-size:2.25rem;line-height:2.5rem;"
|
||||
"5xl" "font-size:3rem;line-height:1;"
|
||||
"6xl" "font-size:3.75rem;line-height:1;"
|
||||
"7xl" "font-size:4.5rem;line-height:1;"
|
||||
"8xl" "font-size:6rem;line-height:1;"
|
||||
"9xl" "font-size:8rem;line-height:1;"})
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Font weights — named weight → numeric value
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(define cssx-weights
|
||||
{"thin" "100"
|
||||
"extralight" "200"
|
||||
"light" "300"
|
||||
"normal" "400"
|
||||
"medium" "500"
|
||||
"semibold" "600"
|
||||
"bold" "700"
|
||||
"extrabold" "800"
|
||||
"black" "900"})
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Font families — named family → CSS font stack
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(define cssx-families
|
||||
{"sans" "ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,\"Noto Sans\",sans-serif"
|
||||
"serif" "ui-serif,Georgia,Cambria,\"Times New Roman\",Times,serif"
|
||||
"mono" "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace"})
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Standalone modifier functions — return CSS declaration strings
|
||||
;; Each returns a complete CSS declaration string. Use inside cssx groups
|
||||
;; or standalone on :style with str.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; -- Typography --
|
||||
|
||||
(define size
|
||||
(fn (s) (or (get cssx-sizes s) (str "font-size:" s ";"))))
|
||||
|
||||
(define weight
|
||||
(fn (w)
|
||||
(let ((v (get cssx-weights w)))
|
||||
(str "font-weight:" (or v w) ";"))))
|
||||
|
||||
(define family
|
||||
(fn (f)
|
||||
(let ((v (get cssx-families f)))
|
||||
(str "font-family:" (or v f) ";"))))
|
||||
|
||||
(define align
|
||||
(fn (a) (str "text-align:" a ";")))
|
||||
|
||||
(define decoration
|
||||
(fn (d) (str "text-decoration:" d ";")))
|
||||
|
||||
;; -- Spacing (Tailwind scale: 1 unit = 0.25rem) --
|
||||
|
||||
(define spacing (fn (n) (str (* n 0.25) "rem")))
|
||||
|
||||
(define p (fn (n) (str "padding:" (spacing n) ";")))
|
||||
(define px (fn (n) (str "padding-left:" (spacing n) ";padding-right:" (spacing n) ";")))
|
||||
(define py (fn (n) (str "padding-top:" (spacing n) ";padding-bottom:" (spacing n) ";")))
|
||||
(define pt (fn (n) (str "padding-top:" (spacing n) ";")))
|
||||
(define pb (fn (n) (str "padding-bottom:" (spacing n) ";")))
|
||||
(define pl (fn (n) (str "padding-left:" (spacing n) ";")))
|
||||
(define pr (fn (n) (str "padding-right:" (spacing n) ";")))
|
||||
|
||||
(define m (fn (n) (str "margin:" (spacing n) ";")))
|
||||
(define mx (fn (n) (str "margin-left:" (spacing n) ";margin-right:" (spacing n) ";")))
|
||||
(define my (fn (n) (str "margin-top:" (spacing n) ";margin-bottom:" (spacing n) ";")))
|
||||
(define mt (fn (n) (str "margin-top:" (spacing n) ";")))
|
||||
(define mb (fn (n) (str "margin-bottom:" (spacing n) ";")))
|
||||
(define ml (fn (n) (str "margin-left:" (spacing n) ";")))
|
||||
(define mr (fn (n) (str "margin-right:" (spacing n) ";")))
|
||||
(define mx-auto (fn () "margin-left:auto;margin-right:auto;"))
|
||||
|
||||
;; -- Display & layout --
|
||||
|
||||
(define display (fn (d) (str "display:" d ";")))
|
||||
(define max-w (fn (w) (str "max-width:" w ";")))
|
||||
|
||||
;; Named max-widths (Tailwind scale)
|
||||
(define cssx-max-widths
|
||||
{"xs" "20rem" "sm" "24rem" "md" "28rem"
|
||||
"lg" "32rem" "xl" "36rem" "2xl" "42rem"
|
||||
"3xl" "48rem" "4xl" "56rem" "5xl" "64rem"
|
||||
"6xl" "72rem" "7xl" "80rem"
|
||||
"full" "100%" "none" "none"})
|
||||
79
sx/sx/essays/hegelian-synthesis.sx
Normal file
79
sx/sx/essays/hegelian-synthesis.sx
Normal file
@@ -0,0 +1,79 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; The Hegelian Synthesis of Hypertext and Reactivity
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~essay-hegelian-synthesis ()
|
||||
(~doc-page :title "The Hegelian Synthesis"
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"On the dialectical resolution of the hypertext/reactive contradiction.")
|
||||
(~doc-section :title "I. Thesis: The server renders" :id "thesis"
|
||||
(p :class "text-stone-600"
|
||||
"In the beginning was the hyperlink. The web was born as a system of documents connected by references. A page was a " (em "representation") " — complete, self-contained, delivered whole by the server. The browser was a thin client. It received, it rendered, it followed links. The server was the sole author of state.")
|
||||
(p :class "text-stone-600"
|
||||
"This is the thesis of the web: " (strong "the server knows") ". It knows what to show. It knows what state you're in. It knows what actions are available. It encodes all of this in the document it sends you. When you click a link, the server sends a new document. When you submit a form, the server processes it and sends another document. The cycle is: request, represent, repeat.")
|
||||
(p :class "text-stone-600"
|
||||
"Hegel would recognise this as a form of " (em "Substance") " — the server as the unmoved mover, the ground of all content. The client has no interiority. It does not " (em "decide") " anything. It displays what it is told to display. The web page is Spinozistic: a mode of the server, one of its infinite expressions, determined entirely by its cause.")
|
||||
(p :class "text-stone-600"
|
||||
"htmx is the most faithful modern expression of this thesis. It extends the hyperlink and the form — the two primordial hypermedia controls — to every element and every HTTP verb, but it does not break the paradigm. The server still renders. The server still knows. The server still authors state. htmx simply makes the authoring more fine-grained: instead of replacing the entire page, the server replaces a fragment. The thesis is refined, not rejected.")
|
||||
(p :class "text-stone-600"
|
||||
"The beauty of the thesis is its simplicity. One source of truth. One rendering pipeline. No synchronisation problems. No stale state. No " (code "useEffect") " cleanup. Every request produces a fresh representation, and the representation " (em "is") " the application. There is nothing hidden behind it, no shadow state, no ghost in the machine.")
|
||||
(p :class "text-stone-600"
|
||||
"But the thesis has a limit. The server cannot know everything the client experiences. It cannot know that the user's mouse is hovering over a button. It cannot know that a drag is in progress. It cannot know that a counter should increment " (em "now") ", this millisecond, without a round trip. The thesis renders the world from the server's perspective — and the client's perspective, its " (em "Erlebnis") ", its lived experience, is absent."))
|
||||
(~doc-section :title "II. Antithesis: The client reacts" :id "antithesis"
|
||||
(p :class "text-stone-600"
|
||||
"React arrived as the negation of the server-rendered web. Where the thesis said " (em "the server knows") ", React said " (em "the client knows better") ". Where the thesis treated the browser as a display surface, React treated it as an application runtime. Where the thesis sent documents, React sent " (em "programs") ".")
|
||||
(p :class "text-stone-600"
|
||||
"The Hegelian antithesis is not mere opposition — it is " (em "determinate negation") ". React does not simply reject server rendering. It rejects the specific limitation that the thesis cannot overcome: the absence of immediate, client-local state. React gives the client " (em "interiority") ". The component has state. It has a lifecycle. It makes decisions without consulting the server. It " (em "reacts") ".")
|
||||
(p :class "text-stone-600"
|
||||
"This is the birth of " (em "Subject") " in the Hegelian sense. The client is no longer pure receptivity. It has " (em "Selbstbewusstsein") " — self-consciousness. It knows its own state. A " (code "useState") " hook is an act of self-positing: the component declares " (em "I have an interior") ". A " (code "useEffect") " is self-reflection: the component observes its own changes and responds. The client becomes, in miniature, a knowing subject.")
|
||||
(p :class "text-stone-600"
|
||||
"But the antithesis inherits its own contradiction. By giving the client interiority, React creates " (em "two sources of truth") ". The server has its state. The client has its state. They must be synchronised. And synchronisation is the eternal problem of distributed systems — it cannot be solved, only managed. Hence the endless parade of state management libraries, cache invalidation strategies, optimistic updates, revalidation hooks. Each is an attempt to paper over the fundamental contradiction: the client and server both claim to know, and they do not always agree.")
|
||||
(p :class "text-stone-600"
|
||||
"Worse, the antithesis destroys what the thesis had achieved. The representation is no longer self-contained. A React SPA sends a JavaScript bundle — a program, not a document. The server sends an empty " (code "<div id=\"root\">") " and a prayer. The browser must compile, execute, fetch data, and construct the interface from scratch. The document — the web's primordial unit of meaning — is hollowed out. What arrives is not a representation but an " (em "instruction to construct one") ".")
|
||||
(p :class "text-stone-600"
|
||||
"Hegel would diagnose this as the antithesis's characteristic failure: it achieves freedom (client autonomy) at the cost of substance (server authority). The SPA is the " (em "beautiful soul") " of web development — pure subjectivity that has cut itself off from the objective world and wonders why everything is so complicated."))
|
||||
(~doc-section :title "III. The contradiction in practice" :id "contradiction"
|
||||
(p :class "text-stone-600"
|
||||
"The practical manifestation of the dialectic is visible in every web team's daily life. The server-rendered camp says: " (em "just use HTML and htmx, it's simpler") ". The React camp says: " (em "you can't build a real app without client state") ". Both are correct. Both are incomplete.")
|
||||
(p :class "text-stone-600"
|
||||
"The server camp cannot build a colour picker. Cannot build a drag-and-drop interface. Cannot build a spreadsheet. Cannot build anything that requires the client to know its own state between HTTP requests. Every interaction that needs immediacy — every tooltip, every animation, every character-by-character validation — forces the server camp to smuggle in JavaScript through the back door, breaking their own thesis.")
|
||||
(p :class "text-stone-600"
|
||||
"The React camp cannot deliver a page without JavaScript. Cannot render on first load without a server-rendering framework bolted on top. Cannot cache a representation because there is no stable representation to cache. Cannot inspect a page without devtools because the document is an empty shell. Every improvement — server components, streaming SSR, partial hydration — is an attempt to recover what the thesis already had: server-authored, self-contained documents.")
|
||||
(p :class "text-stone-600"
|
||||
"The two camps are not in disagreement about different things. They are in disagreement about " (em "the same thing") ": where should state live? The thesis says: on the server. The antithesis says: on the client. Neither can accommodate the obvious truth that " (strong "some state belongs on the server and some belongs on the client") ", and that a coherent architecture must handle both without privileging either."))
|
||||
(~doc-section :title "IV. Synthesis: The island in the lake" :id "synthesis"
|
||||
(p :class "text-stone-600"
|
||||
"Hegel's dialectic does not end in compromise. The synthesis is not half-thesis, half-antithesis. It is a new category that " (em "sublates") " — " (em "aufhebt") " — both: preserving what is true in each while resolving the contradiction between them. The synthesis contains the thesis and antithesis as " (em "moments") " within a higher unity.")
|
||||
(p :class "text-stone-600"
|
||||
"The island architecture is this synthesis. The " (em "lake") " is the thesis preserved: server-rendered HTML, delivered as complete representations, navigated by hypermedia controls. The " (em "island") " is the antithesis preserved: client-local state, reactive signals, immediate interaction. Neither is primary. Neither is a concession to the other. Both are " (em "moments") " of the same page.")
|
||||
(p :class "text-stone-600"
|
||||
"But the crucial move — the one that makes this a genuine Hegelian synthesis rather than a mere juxtaposition — is " (strong "the morph") ". When the server sends new content and the client merges it into the existing DOM, hydrated islands are " (em "preserved") ". The server updates the lake. The islands keep their state. The server's new representation flows around the islands like water around rocks. The client's interiority survives the server's authority.")
|
||||
(p :class "text-stone-600"
|
||||
"This is " (em "Aufhebung") " in its precise meaning: cancellation, preservation, and elevation. The thesis (server authority) is " (em "cancelled") " — the server no longer has total control over the page. It is " (em "preserved") " — the server still renders the document, still determines structure, still delivers representations. It is " (em "elevated") " — the server now renders " (em "around") " reactive islands, acknowledging their autonomy. Simultaneously, the antithesis (client autonomy) is cancelled (the client no longer controls the whole page), preserved (islands keep their state), and elevated (client state now coexists with server-driven updates).")
|
||||
(~doc-code :code (highlight ";; The server sends this. The island already exists\n;; in the DOM with reactive state. The morph preserves it.\n\n(defisland ~sx-header ()\n (let ((families (list \"violet\" \"rose\" \"blue\" \"emerald\"))\n (idx (signal 0))\n (current (computed (fn ()\n (nth families (mod (deref idx) (len families)))))))\n (a :href \"/\" :sx-get \"/\" :sx-target \"#main-panel\"\n (span :style (cssx (:text (colour (deref current) 500)))\n :on-click (fn (e) (swap! idx inc))\n \"reactive\"))))\n\n;; Click: colour changes (client state)\n;; Click also triggers sx-get (server fetch)\n;; Server response morphs the page\n;; Island keeps its colour — state survives the swap" "lisp"))
|
||||
(p :class "text-stone-600"
|
||||
"In this example, the word " (em "reactive") " cycles through colours on click. The same click triggers a server navigation. The server responds with a fresh page. The morph algorithm encounters the island, recognises it as already hydrated, and " (em "skips it") ". The signal — the " (code "idx") " that tracks which colour family we're on — survives. The client's inner life persists through the server's outer renewal."))
|
||||
(~doc-section :title "V. Spirit: The self-knowing page" :id "spirit"
|
||||
(p :class "text-stone-600"
|
||||
"Hegel's system does not end with synthesis. Synthesis becomes a new thesis, which generates its own antithesis, and the dialectic continues. The island architecture is not a final resting place. It is a " (em "moment") " in the self-development of the web.")
|
||||
(p :class "text-stone-600"
|
||||
"Consider what the synthesis makes possible. The page now has two modes of knowledge: the server's knowledge (what the page should contain) and the client's knowledge (what the user is doing right now). Neither is reducible to the other. Neither is privileged over the other. The page is, in Hegel's terminology, " (em "Spirit") " — " (em "Geist") " — the unity of substance and subject, the place where objective content and subjective experience meet.")
|
||||
(p :class "text-stone-600"
|
||||
"The self-hosting nature of SX deepens this. The evaluator that renders islands is specified in the same language as the islands themselves (" (code "eval.sx") "). The renderer is specified in the same language (" (code "render.sx") "). The parser is specified in the same language (" (code "parser.sx") "). The system knows itself. It is a specification that specifies its own interpretation. This is Hegel's " (em "absolute knowing") " — not omniscience, but self-transparency. The system is what it knows, and it knows what it is.")
|
||||
(p :class "text-stone-600"
|
||||
"The morph algorithm is the phenomenological crux. It is the mechanism by which the page achieves continuity of experience through discontinuity of content. The server sends a completely new representation — new HTML, new structure, new text. The morph walks the old and new DOM trees, reconciling them. Where it finds an island — a locus of client subjectivity — it preserves it. Where it finds static content — server substance — it updates it. The result is a page that is simultaneously the same (the island's state persists) and different (the surrounding content has changed).")
|
||||
(p :class "text-stone-600"
|
||||
"This is Hegel's " (em "identity of identity and difference") ". The page after the morph is the same page (same islands, same signals, same DOM nodes) and a different page (new server content, new navigation state, new URL). The dialectic is not resolved by eliminating one side. It is resolved by maintaining both simultaneously — and the morph is the concrete mechanism that achieves this."))
|
||||
(~doc-section :title "VI. The speculative proposition" :id "speculative"
|
||||
(p :class "text-stone-600"
|
||||
"Hegel distinguished " (em "ordinary") " propositions from " (em "speculative") " ones. An ordinary proposition has a fixed subject and a predicate attached to it from outside: " (em "the rose is red") ". A speculative proposition is one where the predicate reflects back on the subject and transforms it: " (em "the actual is the rational") ".")
|
||||
(p :class "text-stone-600"
|
||||
"The proposition " (em "React is hypermedia") " is speculative in this sense. It does not mean that React, as it exists, is hypermedia. It means that what React " (em "was trying to be") " — a way to specify interactive UI — is what hypermedia " (em "always already was") " — a way to specify interactive documents. The predicate (hypermedia) transforms the subject (React): once you see that reactive islands are hypermedia controls, you can no longer see React as merely a JavaScript library. It was always an attempt to extend the expressiveness of hypermedia. It just didn't know it.")
|
||||
(p :class "text-stone-600"
|
||||
"And the converse: " (em "hypermedia is reactive") ". The hyperlink is already a reactive control — it responds to user input (click) by producing a state change (navigation). The form is reactive — it responds to submission by producing a state change (server-side processing + new representation). htmx makes this explicit: any element can react to any event by triggering any HTTP verb. The only thing htmx lacks is " (em "local") " reactivity — the ability to change without consulting the server.")
|
||||
(p :class "text-stone-600"
|
||||
"Islands supply exactly this. And by doing so, they do not add something foreign to hypermedia. They complete it. They give hypermedia the last degree of freedom it was missing: the ability for a control to react to itself, to maintain its own state, to compute derived values from its own inputs — without breaking the hypermedia contract that the server is the author of the document.")
|
||||
(p :class "text-stone-600"
|
||||
"The Hegelian synthesis is not a compromise between server rendering and client reactivity. It is the recognition that they were always the same thing seen from different sides. The server renders the document. The document contains controls. Some controls maintain local state. The document flows around them. The state persists through the flow. The server and the client are not two systems bridged together. They are one system that knows itself from two perspectives.")
|
||||
(p :class "text-stone-600"
|
||||
"Click the word " (em "reactive") " in the header above. The colour changes. The page navigates. The colour survives. That is the Hegelian synthesis of the hypertext/reactive dialectic — not in theory, but in the DOM."))))
|
||||
@@ -5,21 +5,40 @@
|
||||
;; Nav components — logo header, sibling arrows, children links
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; @css text-violet-700 text-violet-600 text-violet-500 text-stone-400 text-stone-500 text-stone-600
|
||||
;; @css hover:text-violet-600 hover:text-violet-700 hover:bg-violet-50
|
||||
;; @css bg-violet-50 border-violet-200 border
|
||||
;; CSSX replaces Tailwind text-*/bg-*/font-* classes — computed via cssx.sx
|
||||
|
||||
;; Logo + tagline + copyright — always shown at top of page area.
|
||||
(defcomp ~sx-header ()
|
||||
(a :href "/"
|
||||
:sx-get "/" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center no-underline"
|
||||
(span :class "text-4xl font-bold font-mono text-violet-700 block mb-2" "(<sx>)")
|
||||
(p :class "text-lg text-stone-500 mb-1"
|
||||
"Framework free reactive hypermedia")
|
||||
(p :class "text-xs text-stone-400"
|
||||
"© Giles Bradshaw 2026")))
|
||||
;; The header itself is an island so the "reactive" word can cycle colours
|
||||
;; on click — demonstrates inline signals without a separate component.
|
||||
(defisland ~sx-header ()
|
||||
(let ((families (list "violet" "rose" "blue" "emerald" "amber" "cyan" "red" "teal" "pink" "indigo"))
|
||||
(idx (signal 0))
|
||||
(shade (signal 500))
|
||||
(current-family (computed (fn ()
|
||||
(nth families (mod (deref idx) (len families)))))))
|
||||
(a :href "/"
|
||||
:sx-get "/" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:style (str (display "block") (max-w (get cssx-max-widths "3xl"))
|
||||
(mx-auto) (px 4) (pt 8) (pb 4) (align "center")
|
||||
(decoration "none"))
|
||||
(span :style (str (display "block") (mb 2)
|
||||
(cssx (:text (colour "violet" 699) (size "4xl") (weight "bold") (family "mono"))))
|
||||
"(<sx>)")
|
||||
(p :style (str (mb 1) (cssx (:text (colour "stone" 500) (size "lg"))))
|
||||
"Framework free "
|
||||
(span
|
||||
:style (str (cssx (:text (colour (deref current-family) (deref shade))
|
||||
(weight "bold")))
|
||||
"cursor:pointer;transition:color 0.3s,font-weight 0.3s;")
|
||||
:on-click (fn (e)
|
||||
(batch (fn ()
|
||||
(swap! idx inc)
|
||||
(reset! shade (+ 400 (* (mod (* (deref idx) 137) 5) 50))))))
|
||||
"reactive")
|
||||
" hypermedia")
|
||||
(p :style (cssx (:text (colour "stone" 400) (size "xs")))
|
||||
"© Giles Bradshaw 2026"))))
|
||||
|
||||
;; @css grid grid-cols-3
|
||||
|
||||
@@ -40,21 +59,24 @@
|
||||
:sx-get (get prev-node "href") :sx-target "#main-panel"
|
||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:class "text-sm text-stone-500 hover:text-violet-600 text-right"
|
||||
:class "text-right"
|
||||
:style (cssx (:text (colour "stone" 500) (size "sm")))
|
||||
(str "← " (get prev-node "label")))
|
||||
(a :href (get node "href")
|
||||
:sx-get (get node "href") :sx-target "#main-panel"
|
||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:class (if is-leaf
|
||||
"text-2xl font-bold text-violet-700 text-center px-4"
|
||||
"text-lg font-semibold text-violet-700 text-center px-4")
|
||||
:class "text-center px-4"
|
||||
:style (if is-leaf
|
||||
(cssx (:text (colour "violet" 700) (size "2xl") (weight "bold")))
|
||||
(cssx (:text (colour "violet" 700) (size "lg") (weight "semibold"))))
|
||||
(get node "label"))
|
||||
(a :href (get next-node "href")
|
||||
:sx-get (get next-node "href") :sx-target "#main-panel"
|
||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:class "text-sm text-stone-500 hover:text-violet-600 text-left"
|
||||
:class "text-left"
|
||||
:style (cssx (:text (colour "stone" 500) (size "sm")))
|
||||
(str (get next-node "label") " →")))))))
|
||||
|
||||
;; Children links — shown as clearly clickable buttons.
|
||||
@@ -66,7 +88,9 @@
|
||||
:sx-get (get item "href") :sx-target "#main-panel"
|
||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:class "px-3 py-1.5 text-sm rounded border border-violet-200 text-violet-700 hover:bg-violet-50 transition-colors"
|
||||
:class "px-3 py-1.5 rounded border transition-colors"
|
||||
:style (cssx (:text (colour "violet" 700) (size "sm"))
|
||||
(:border (colour "violet" 200)))
|
||||
(get item "label")))
|
||||
items))))
|
||||
|
||||
|
||||
@@ -93,7 +93,9 @@
|
||||
(dict :label "Tools for Fools" :href "/essays/zero-tooling"
|
||||
:summary "SX was built without a code editor. No IDE, no build tools, no linters, no bundlers. What zero-tooling web development looks like.")
|
||||
(dict :label "React is Hypermedia" :href "/essays/react-is-hypermedia"
|
||||
:summary "A React Island is a hypermedia control. Its behavior is specified in SX.")))
|
||||
:summary "A React Island is a hypermedia control. Its behavior is specified in SX.")
|
||||
(dict :label "The Hegelian Synthesis" :href "/essays/hegelian-synthesis"
|
||||
:summary "On the dialectical resolution of the hypertext/reactive contradiction. Thesis: the server renders. Antithesis: the client reacts. Synthesis: the island in the lake.")))
|
||||
|
||||
(define philosophy-nav-items (list
|
||||
(dict :label "The SX Manifesto" :href "/philosophy/sx-manifesto"
|
||||
@@ -194,7 +196,11 @@
|
||||
(dict :label "sx-swarm" :href "/plans/sx-swarm"
|
||||
:summary "Container orchestration in SX — service definitions, environment macros, deploy pipelines. Replace YAML with a real language.")
|
||||
(dict :label "sx-proxy" :href "/plans/sx-proxy"
|
||||
:summary "Reverse proxy in SX — routes, TLS, middleware chains, load balancing. Macros generate config from the same service definitions as the orchestrator.")))
|
||||
:summary "Reverse proxy in SX — routes, TLS, middleware chains, load balancing. Macros generate config from the same service definitions as the orchestrator.")
|
||||
(dict :label "Async Eval Convergence" :href "/plans/async-eval-convergence"
|
||||
:summary "Eliminate hand-written evaluators — bootstrap async_eval.py from the spec via an async adapter layer. One spec, one truth, zero divergence.")
|
||||
(dict :label "WASM Bytecode VM" :href "/plans/wasm-bytecode-vm"
|
||||
:summary "Compile SX to bytecode, run in a Rust/WASM VM. Compact wire format, no parse overhead, near-native speed, DOM via JS bindings.")))
|
||||
|
||||
(define reactive-islands-nav-items (list
|
||||
(dict :label "Overview" :href "/reactive-islands/"
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
"no-alternative" (~essay-no-alternative)
|
||||
"zero-tooling" (~essay-zero-tooling)
|
||||
"react-is-hypermedia" (~essay-react-is-hypermedia)
|
||||
"hegelian-synthesis" (~essay-hegelian-synthesis)
|
||||
:else (~essays-index-content))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -516,6 +517,8 @@
|
||||
"sx-forge" (~plan-sx-forge-content)
|
||||
"sx-swarm" (~plan-sx-swarm-content)
|
||||
"sx-proxy" (~plan-sx-proxy-content)
|
||||
"async-eval-convergence" (~plan-async-eval-convergence-content)
|
||||
"wasm-bytecode-vm" (~plan-wasm-bytecode-vm-content)
|
||||
:else (~plans-index-content))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,6 +13,7 @@ def _register_sx_helpers() -> None:
|
||||
|
||||
register_page_helpers("sx", {
|
||||
"highlight": _highlight,
|
||||
"component-source": _component_source,
|
||||
"primitives-data": _primitives_data,
|
||||
"special-forms-data": _special_forms_data,
|
||||
"reference-data": _reference_data,
|
||||
@@ -35,6 +36,33 @@ def _register_sx_helpers() -> None:
|
||||
})
|
||||
|
||||
|
||||
def _component_source(name: str) -> str:
|
||||
"""Return the pretty-printed defcomp/defisland source for a named component."""
|
||||
from shared.sx.jinja_bridge import get_component_env
|
||||
from shared.sx.parser import serialize
|
||||
from shared.sx.types import Component, Island
|
||||
|
||||
comp = get_component_env().get(name)
|
||||
if isinstance(comp, Island):
|
||||
param_strs = list(comp.params)
|
||||
if comp.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(comp.body, pretty=True)
|
||||
return f"(defisland {name} {params_sx}\n {body_sx})"
|
||||
if not isinstance(comp, Component):
|
||||
return f";; component {name} not found"
|
||||
param_strs = ["&key"] + list(comp.params)
|
||||
if comp.has_children:
|
||||
param_strs.extend(["&rest", "children"])
|
||||
params_sx = "(" + " ".join(param_strs) + ")"
|
||||
body_sx = serialize(comp.body, pretty=True)
|
||||
affinity = ""
|
||||
if comp.render_target == "server":
|
||||
affinity = " :affinity :server"
|
||||
return f"(defcomp {name} {params_sx}{affinity}\n {body_sx})"
|
||||
|
||||
|
||||
def _primitives_data() -> dict:
|
||||
"""Return the PRIMITIVES dict for the primitives docs page."""
|
||||
from content.pages import PRIMITIVES
|
||||
|
||||
Reference in New Issue
Block a user