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"})
|
||||
Reference in New Issue
Block a user