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:
2026-03-10 14:10:35 +00:00
parent 8a5c115557
commit d5e416e478
15 changed files with 458 additions and 5440 deletions

View File

@@ -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