From 5b70cd5cfc9fe5d964a27a135daa4f1417ac9088 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 8 Mar 2026 10:59:58 +0000 Subject: [PATCH] Spec event bridge and named stores, move plan to reactive islands section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signals.sx: add def-store/use-store/clear-stores (L3 named stores) and emit-event/on-event/bridge-event (lake→island DOM events) - reactive-islands.sx: add event bridge, named stores, and plan pages - Remove ~plan-reactive-islands-content from plans.sx - Update nav-data.sx and docs.sx routing accordingly Co-Authored-By: Claude Opus 4.6 --- shared/sx/ref/signals.sx | 84 +++++++++++++ sx/sx/nav-data.sx | 10 +- sx/sx/plans.sx | 256 -------------------------------------- sx/sx/reactive-islands.sx | 253 ++++++++++++++++++++++++++++++++++++- sx/sxc/pages/docs.sx | 4 +- 5 files changed, 340 insertions(+), 267 deletions(-) diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index 4d6d1c1..f37ad44 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -288,3 +288,87 @@ (fn (disposable) (when *island-scope* (*island-scope* disposable)))) + + +;; ========================================================================== +;; 12. Named stores — page-level signal containers (L3) +;; ========================================================================== +;; +;; Stores persist across island creation/destruction. They live at page +;; scope, not island scope. When an island is swapped out and re-created, +;; it reconnects to the same store instance. +;; +;; The store registry is global page-level state. It survives island +;; disposal but is cleared on full page navigation. + +(define *store-registry* (dict)) + +(define def-store + (fn (name init-fn) + (let ((registry *store-registry*)) + ;; Only create the store once — subsequent calls return existing + (when (not (has? registry name)) + (set! *store-registry* (assoc registry name (init-fn)))) + (get *store-registry* name)))) + +(define use-store + (fn (name) + (if (has? *store-registry* name) + (get *store-registry* name) + (error (str "Store not found: " name + ". Call (def-store ...) before (use-store ...)."))))) + +(define clear-stores + (fn () + (set! *store-registry* (dict)))) + + +;; ========================================================================== +;; 13. Event bridge — DOM event communication for lake→island +;; ========================================================================== +;; +;; Server-rendered content ("htmx lakes") inside reactive islands can +;; communicate with island signals via DOM custom events. The bridge +;; pattern: +;; +;; 1. Server renders a button/link with data-sx-emit="event-name" +;; 2. When clicked, the client dispatches a CustomEvent on the element +;; 3. The event bubbles up to the island container +;; 4. An island effect listens for the event and updates signals +;; +;; This keeps server content pure HTML — no signal references needed. +;; The island effect is the only reactive code. +;; +;; Platform interface required: +;; (dom-listen el event-name handler) → remove-fn +;; (dom-dispatch el event-name detail) → void +;; (event-detail e) → any +;; +;; These are platform primitives because they require browser DOM APIs. + +(define emit-event + (fn (el event-name detail) + (dom-dispatch el event-name detail))) + +(define on-event + (fn (el event-name handler) + (dom-listen el event-name handler))) + +;; Convenience: create an effect that listens for a DOM event on an +;; element and writes the event detail (or a transformed value) into +;; a target signal. Returns the effect's dispose function. +;; When the effect is disposed (island teardown), the listener is +;; removed automatically via the cleanup return. + +(define bridge-event + (fn (el event-name target-signal transform-fn) + (effect (fn () + (let ((remove (dom-listen el event-name + (fn (e) + (let ((detail (event-detail e)) + (new-val (if transform-fn + (transform-fn detail) + detail))) + (reset! target-signal new-val)))))) + ;; Return cleanup — removes listener on dispose/re-run + remove))))) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 7127260..5b637ad 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -160,16 +160,18 @@ (dict :label "SX CI Pipeline" :href "/plans/sx-ci" :summary "Build, test, and deploy in s-expressions — CI pipelines as SX components.") (dict :label "Live Streaming" :href "/plans/live-streaming" - :summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration.") - (dict :label "Reactive Islands" :href "/plans/reactive-islands" - :summary "Client-side state via signals and islands — a sliding bar between hypermedia and React, orthogonal to the server/client rendering bar."))) + :summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration."))) (define reactive-islands-nav-items (list (dict :label "Overview" :href "/reactive-islands/" :summary "Architecture, four levels (L0-L3), and current implementation status.") (dict :label "Demo" :href "/reactive-islands/demo" :summary "Live demonstration of signals, computed, effects, batch, and defisland — all transpiled from spec.") - (dict :label "Plan" :href "/plans/reactive-islands" + (dict :label "Event Bridge" :href "/reactive-islands/event-bridge" + :summary "DOM events for htmx lake → island communication. Server-rendered buttons dispatch custom events that island effects listen for.") + (dict :label "Named Stores" :href "/reactive-islands/named-stores" + :summary "Page-level signal containers via def-store/use-store — persist across island destruction/recreation.") + (dict :label "Plan" :href "/reactive-islands/plan" :summary "The full design document — rendering boundary, state flow, signal primitives, island lifecycle."))) (define bootstrappers-nav-items (list diff --git a/sx/sx/plans.sx b/sx/sx/plans.sx index 9e2cf60..9814199 100644 --- a/sx/sx/plans.sx +++ b/sx/sx/plans.sx @@ -2473,260 +2473,4 @@ (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/boundary.sx") (td :class "px-3 py-2 text-stone-700" "SSE/WS IO primitive declarations"))))))) -;; --------------------------------------------------------------------------- -;; Reactive Islands: Client State via Signals -;; --------------------------------------------------------------------------- - -(defcomp ~plan-reactive-islands-content () - (~doc-page :title "Reactive Islands" - - ;; ----------------------------------------------------------------------- - ;; Context - ;; ----------------------------------------------------------------------- - - (~doc-section :title "Context" :id "context" - (p "SX already has a sliding bar for " (em "where") " rendering happens — server-side HTML, SX wire format for client rendering, or any point between. This is the isomorphism bar. It controls the render boundary.") - (p "There is a second bar, orthogonal to the first: " (em "how state flows.") " On one end, all state lives on the server — every user action is a round-trip, every UI update is a fresh render. This is the htmx model. On the other end, state lives on the client — signals, subscriptions, fine-grained DOM updates without server involvement. This is the React model.") - (p "These two bars are independent. You can have server-rendered HTML with client state (SSR + hydrated React). You can have client-rendered components with server state (current SX). The combination creates four quadrants:") - - (div :class "overflow-x-auto mt-4 mb-4" - (table :class "w-full text-sm text-left" - (thead - (tr :class "border-b border-stone-200" - (th :class "py-2 px-3 font-semibold text-stone-700" "") - (th :class "py-2 px-3 font-semibold text-stone-700" "Server State") - (th :class "py-2 px-3 font-semibold text-stone-700" "Client State"))) - (tbody :class "text-stone-600" - (tr :class "border-b border-stone-100" - (td :class "py-2 px-3 font-semibold text-stone-700" "Server Rendering") - (td :class "py-2 px-3" "Pure hypermedia (htmx)") - (td :class "py-2 px-3" "SSR + hydrated islands (Next.js)")) - (tr :class "border-b border-stone-100" - (td :class "py-2 px-3 font-semibold text-stone-700" "Client Rendering") - (td :class "py-2 px-3" "SX wire format (current)") - (td :class "py-2 px-3 font-semibold text-violet-700" "Reactive islands (this plan)"))))) - - (p "Today SX occupies the bottom-left quadrant — client-rendered components with server state. This plan adds the bottom-right: " (strong "reactive islands") " with client-local signals. A page can mix all four quadrants. Most content stays hypermedia. Interactive regions opt into reactivity. The author controls where each component sits on both bars.")) - - ;; ----------------------------------------------------------------------- - ;; The Spectrum - ;; ----------------------------------------------------------------------- - - (~doc-section :title "The Spectrum" :id "spectrum" - (p "Four levels of client interactivity. Each is independently valuable. Each is opt-in per component.") - - (~doc-subsection :title "Level 0: Pure Hypermedia" - (p "The default. " (code "sx-get") ", " (code "sx-post") ", " (code "sx-swap") ". Server renders everything. Client swaps fragments. No client state. No JavaScript state management. This is where 90% of a typical application should live.")) - - (~doc-subsection :title "Level 1: Local DOM Operations" - (p "Imperative escape hatches for micro-interactions too small for a server round-trip: toggling a menu, switching a tab, showing a tooltip. " (code "toggle!") ", " (code "set-attr!") ", " (code "on-event") ". No reactive graph. Just do the thing directly.")) - - (~doc-subsection :title "Level 2: Reactive Islands" - (p (code "defisland") " components with local signals. Fine-grained DOM updates — no virtual DOM, no diffing, no component re-renders. A signal change updates only the DOM nodes that read it. Islands are isolated by default. The server can render their initial state. This is the core of this plan.")) - - (~doc-subsection :title "Level 3: Connected Islands" - (p "Islands that share state. Pass the same signal to multiple islands and they stay synchronized. Or use a named store for islands that are distant in the DOM tree. This is where SX starts to feel like React — but only in the regions that need it. The surrounding page remains hypermedia."))) - - ;; ----------------------------------------------------------------------- - ;; Signals - ;; ----------------------------------------------------------------------- - - (~doc-section :title "Signals" :id "signals" - (p "The core primitive. A signal is a container for a value that notifies subscribers when it changes. Signals are first-class values — they can be created anywhere, passed as arguments, stored in dicts, shared between islands.") - - (~doc-code :code (highlight ";; Create a signal with an initial value\n(define count (signal 0))\n\n;; Read the current value — subscribes the current reactive context\n(deref count) ;; → 0\n\n;; Write a new value — notifies all subscribers\n(reset! count 5) ;; count is now 5\n\n;; Update via function\n(swap! count inc) ;; count is now 6\n\n;; Derived signal — recomputes when dependencies change\n(define doubled (computed (fn () (* 2 (deref count)))))\n(deref doubled) ;; → 12, auto-updates when count changes\n\n;; Side effect — runs when dependencies change\n(effect (fn ()\n (log (str \"Count is now: \" (deref count)))))" "lisp")) - - (~doc-subsection :title "Why signals, not useState" - (p (code "useState") " ties state to a component instance. Signals are independent values. This matters:") - (ul :class "space-y-2 text-stone-600" - (li (strong "No positional hooks.") " Signals can be created in conditionals, loops, closures. No rules of hooks.") - (li (strong "Fine-grained updates.") " A signal change updates only the DOM nodes that read it — not the entire component tree. No virtual DOM diffing.") - (li (strong "Shareable.") " Pass a signal to another island and both stay synchronized. No Context, no Provider, no useSelector.") - (li (strong "Composable.") " " (code "computed") " derives new signals from existing ones. The dependency graph builds itself."))) - - (~doc-subsection :title "Reactive context" - (p "Inside an island's rendering, " (code "deref") " subscribes the current DOM node to the signal. Outside an island (server render, static component), " (code "deref") " just returns the current value — no subscription, no overhead. The reactive context is the island boundary.") - (p "This means the same component code works in both contexts. " (code "(span (deref count))") " renders " (code "0") " on the server and creates a reactive text binding on the client."))) - - ;; ----------------------------------------------------------------------- - ;; Islands - ;; ----------------------------------------------------------------------- - - (~doc-section :title "Islands" :id "islands" - (p (code "defisland") " is like " (code "defcomp") " but creates a reactive boundary. Inside an island, signals are tracked. Outside, everything is static hypermedia.") - - (~doc-code :code (highlight "(defisland ~counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div :class \"counter\"\n (span :class \"text-2xl font-bold\" (deref count))\n (div :class \"flex gap-2 mt-2\"\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (button :on-click (fn (e) (swap! count dec)) \"-\")))))" "lisp")) - - (p "The island is self-contained. " (code "count") " is local state. The buttons modify it. The span updates. Nothing outside the island is affected. No server round-trip.") - - (~doc-subsection :title "Island lifecycle" - (ol :class "space-y-2 text-stone-600 list-decimal list-inside" - (li (strong "Definition: ") (code "defisland") " registers a reactive component in the environment, same as " (code "defcomp") " but with an island flag.") - (li (strong "Server render: ") "Server evaluates the island with initial values. " (code "deref") " returns initial value. Output is HTML with " (code "data-sx-island") " and " (code "data-sx-state") " attributes.") - (li (strong "Hydration: ") "Client finds " (code "data-sx-island") " elements. Creates signals from serialized state. Re-renders the island body in a reactive context. Morphs existing DOM to preserve structure.") - (li (strong "Updates: ") "User interaction triggers signal changes. Subscribed DOM nodes update directly. No re-render of the full island — just the affected nodes.") - (li (strong "Disposal: ") "When the island is removed from the DOM (navigation, swap), all signals and effects created within it are cleaned up."))) - - (~doc-subsection :title "Hydration protocol" - (p "The server renders an island as static HTML annotated with state:") - (~doc-code :code (highlight ";; Server output:\n
\n 0\n
\n \n \n
\n
\n\n;; Client hydration:\n;; 1. Find data-sx-island elements\n;; 2. Parse data-sx-state into signals\n;; 3. Re-render island body in reactive context\n;; 4. Morph against existing DOM (preserves focus, scroll, etc.)" "html")))) - - ;; ----------------------------------------------------------------------- - ;; Connecting Islands - ;; ----------------------------------------------------------------------- - - (~doc-section :title "Connecting Islands" :id "connecting" - (p "Islands are isolated by default. Connect them by sharing signals.") - - (~doc-subsection :title "Shared signals via props" - (p "The simplest pattern: create a signal outside the islands, pass it to both.") - (~doc-code :code (highlight ";; Page-level: create shared state, pass to multiple islands\n(let ((count (signal 0)))\n (<>\n ;; These two islands share the same count signal\n (~counter-controls :count count)\n (div :class \"my-8\"\n (p \"This is static hypermedia content between the islands.\"))\n (~counter-display :count count)))\n\n(defisland ~counter-controls (&key count)\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (button :on-click (fn (e) (swap! count dec)) \"-\")))\n\n(defisland ~counter-display (&key count)\n (div :class \"text-4xl font-bold\" (deref count)))" "lisp")) - (p "Click \"+\" in " (code "~counter-controls") " and " (code "~counter-display") " updates instantly. No server. No event bus. Just a shared reference.")) - - (~doc-subsection :title "Named stores" - (p "For islands that are distant in the DOM tree — or defined in different .sx files — passing props is impractical. Named stores provide ambient shared state:") - (~doc-code :code (highlight ";; Declare a named store (page-level)\n(def-store cart\n {:items (signal (list))\n :count (computed (fn () (length (deref items))))\n :total (computed (fn () (reduce + 0\n (map (fn (i) (get i \"price\")) (deref items)))))})\n\n;; Any island can subscribe by name\n(defisland ~cart-badge ()\n (let ((s (use-store cart)))\n (span :class \"badge\" (deref (get s \"count\")))))\n\n(defisland ~cart-drawer ()\n (let ((s (use-store cart)))\n (ul (map (fn (item)\n (li (get item \"name\") \" — £\" (get item \"price\")))\n (deref (get s \"items\"))))))" "lisp")) - (p "Stores are created once per page. " (code "use-store") " returns the store's signal dict. Multiple islands reading the same store share reactive state without prop threading.")) - - (~doc-subsection :title "Mixing with hypermedia" - (p "Islands coexist with server-driven content. A single page can have:") - (ul :class "space-y-2 text-stone-600" - (li "A navigation bar rendered by the server via " (code "sx-get") " / OOB swap") - (li "A product list rendered server-side with " (code "sx-swap")) - (li "An \"Add to Cart\" island with local quantity state and optimistic update") - (li "A cart badge island connected to the same cart store") - (li "A checkout form that submits via " (code "sx-post") " — back to server")) - (p "The server handles auth, data, routing. Islands handle interactions that need instant feedback. This is not a compromise — it is the architecture."))) - - ;; ----------------------------------------------------------------------- - ;; Reactive DOM Rendering - ;; ----------------------------------------------------------------------- - - (~doc-section :title "Reactive DOM Rendering" :id "reactive-rendering" - (p "The existing " (code "renderDOM") " function walks the AST and creates DOM nodes. Inside an island, it becomes signal-aware:") - - (~doc-subsection :title "Text bindings" - (p "When " (code "renderDOM") " encounters " (code "(deref sig)") " in a text position, it creates a text node and subscribes it to the signal:") - (~doc-code :code (highlight ";; Source\n(span (deref count))\n\n;; renderDOM creates:\n;; const span = document.createElement('span')\n;; const text = document.createTextNode(sig.value)\n;; effect(() => text.nodeValue = sig.value)\n;; span.appendChild(text)" "lisp")) - (p "When " (code "count") " changes, only the text node updates. The span is untouched. No diffing.")) - - (~doc-subsection :title "Attribute bindings" - (p "Signal reads in attribute values create reactive attribute bindings:") - (~doc-code :code (highlight ";; Source\n(div :class (str \"panel \" (if (deref open?) \"visible\" \"hidden\")))\n\n;; renderDOM creates the div, then:\n;; effect(() => div.className = \"panel \" + (sig.value ? \"visible\" : \"hidden\"))" "lisp"))) - - (~doc-subsection :title "Conditional fragments" - (p "When a signal appears inside control flow (" (code "if") ", " (code "when") ", " (code "cond") "), the entire branch becomes a reactive fragment:") - (~doc-code :code (highlight ";; Source\n(when (deref show-details?)\n (~product-details :product product))\n\n;; renderDOM creates a marker node, then:\n;; effect(() => {\n;; if (sig.value)\n;; insert rendered fragment after marker\n;; else\n;; remove fragment\n;; })" "lisp")) - (p "This is equivalent to SolidJS's " (code "Show") " component — but it falls out naturally from the evaluator. No special component needed.")) - - (~doc-subsection :title "List rendering" - (p "Reactive lists use keyed reconciliation:") - (~doc-code :code (highlight "(map (fn (item)\n (li :key (get item \"id\")\n (get item \"name\")))\n (deref items))" "lisp")) - (p "When " (code "items") " changes, the " (code "map") " re-runs. Keyed elements are reused and reordered. Unkeyed elements are morphed. This reuses the existing morph algorithm from SxEngine."))) - - ;; ----------------------------------------------------------------------- - ;; Server Integration - ;; ----------------------------------------------------------------------- - - (~doc-section :title "Server Integration" :id "server" - (p "Islands are server-renderable. The server evaluates the island body with initial signal values. " (code "deref") " returns the value, " (code "reset!") " and " (code "swap!") " are no-ops. The output is static HTML annotated for client hydration.") - - (~doc-subsection :title "SSR render mode" - (p "The Python evaluator already handles " (code "defcomp") ". For " (code "defisland") ", it additionally:") - (ol :class "space-y-1 text-stone-600 list-decimal list-inside" - (li "Tracks signals created during body evaluation") - (li "Wraps output in a " (code "data-sx-island") " container") - (li "Serializes signal initial values to " (code "data-sx-state")) - (li "Renders the body as static HTML (no subscriptions)"))) - - (~doc-subsection :title "Server-to-island updates" - (p "The server can update island state via the existing OOB swap mechanism. A handler response can include " (code "sx-swap-oob") " targeting an island's container, providing new state:") - (~doc-code :code (highlight ";; Server handler response includes:\n(div :id \"cart-island\" :sx-swap-oob \"cart-island\"\n :data-sx-state '{\"items\": [{\"name\": \"Widget\", \"price\": 9.99}]}')\n\n;; Client: receives OOB swap, re-hydrates island with new state" "lisp")) - (p "This bridges server and client state. The server pushes updates; the island incorporates them into its signal graph. Both directions work."))) - - ;; ----------------------------------------------------------------------- - ;; Spec Architecture - ;; ----------------------------------------------------------------------- - - (~doc-section :title "Spec Architecture" :id "spec" - (p "Following the SX-first principle: signal semantics are defined in " (code ".sx") " spec files, then bootstrapped to JavaScript (and eventually Python for SSR).") - - (~doc-subsection :title "signals.sx — the signal runtime" - (p "New spec file defining:") - (ul :class "space-y-1 text-stone-600 list-disc pl-5" - (li (code "signal") " — create a signal container") - (li (code "computed") " — derived signal with automatic dependency tracking") - (li (code "effect") " — side effect that runs when dependencies change, returns cleanup handle") - (li (code "deref") " — read signal value (subscribes in reactive context)") - (li (code "reset!") " — write signal value (notifies subscribers)") - (li (code "swap!") " — update signal via function") - (li (code "batch") " — group multiple signal writes into one notification pass") - (li (code "dispose") " — tear down an effect or computed") - (li "Dependency tracking algorithm: during effect/computed evaluation, " (code "deref") " calls register the signal as a dependency. Re-evaluation clears old deps and rebuilds.")) - (p "Signals are pure computation — no DOM, no IO. The spec is host-agnostic.")) - - (~doc-subsection :title "Changes to existing specs" - (ul :class "space-y-2 text-stone-600 list-disc pl-5" - (li (strong "eval.sx: ") (code "defisland") " special form — like " (code "defcomp") " but sets an island flag on the component.") - (li (strong "adapter-dom.sx: ") "Reactive " (code "renderDOM") " mode — when inside an island context, " (code "deref") " creates subscriptions and " (code "renderList") " wraps control flow in effects.") - (li (strong "adapter-html.sx: ") "SSR rendering of islands — wraps output in " (code "data-sx-island") " container, serializes signal state.") - (li (strong "boundary.sx: ") "Signal primitives declaration — " (code "signal") ", " (code "computed") ", " (code "effect") " as pure primitives; " (code "def-store") ", " (code "use-store") " as page helpers."))) - - (~doc-subsection :title "Bootstrap" - (p (code "bootstrap_js.py") " generates the signal runtime into " (code "sx.js") ". The reactive rendering extensions fold into the existing " (code "renderDOM") " function. No separate file — signals are part of the core evaluator.") - (p (code "bootstrap_py.py") " generates SSR support: " (code "defisland") " handling, " (code "deref") " as plain read, state serialization. The Python side never tracks subscriptions — it only renders the initial state."))) - - ;; ----------------------------------------------------------------------- - ;; Files - ;; ----------------------------------------------------------------------- - - (~doc-section :title "Files" :id "files" - (div :class "overflow-x-auto rounded border border-stone-200" - (table :class "w-full text-left text-sm" - (thead (tr :class "border-b border-stone-200 bg-stone-100" - (th :class "px-3 py-2 font-medium text-stone-600" "File") - (th :class "px-3 py-2 font-medium text-stone-600" "Change"))) - (tbody - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/signals.sx") - (td :class "px-3 py-2 text-stone-700" "New — signal runtime spec: signal, computed, effect, deref, reset!, swap!, batch, dispose, dependency tracking")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/eval.sx") - (td :class "px-3 py-2 text-stone-700" "defisland special form, def-store, use-store")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/adapter-dom.sx") - (td :class "px-3 py-2 text-stone-700" "Reactive renderDOM — signal-aware text/attribute/fragment bindings")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/adapter-html.sx") - (td :class "px-3 py-2 text-stone-700" "SSR island rendering — data-sx-island wrapper, state serialization")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/boundary.sx") - (td :class "px-3 py-2 text-stone-700" "Signal and store primitive declarations")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/bootstrap_js.py") - (td :class "px-3 py-2 text-stone-700" "Transpile signals.sx into sx.js")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/bootstrap_py.py") - (td :class "px-3 py-2 text-stone-700" "Transpile island SSR support into sx_ref.py")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/static/scripts/sx.js") - (td :class "px-3 py-2 text-stone-700" "Rebootstrap — signal runtime + reactive rendering")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/sx_ref.py") - (td :class "px-3 py-2 text-stone-700" "Rebootstrap — island SSR")))))) - - ;; ----------------------------------------------------------------------- - ;; Design Principles - ;; ----------------------------------------------------------------------- - - (~doc-section :title "Design Principles" :id "principles" - (ol :class "space-y-3 text-stone-600 list-decimal list-inside" - (li (strong "Islands are opt-in.") " " (code "defcomp") " remains the default. Components are inert unless you choose " (code "defisland") ". No reactive overhead for static content.") - (li (strong "Signals are values, not hooks.") " Create them anywhere. Pass them as arguments. Store them in dicts. No rules about calling order or conditional creation.") - (li (strong "Fine-grained, not component-grained.") " A signal change updates the specific DOM node that reads it. The island does not re-render. There is no virtual DOM and no diffing beyond the morph algorithm already in SxEngine.") - (li (strong "The server is still the authority.") " Islands handle client interactions. The server handles auth, data, routing. The server can push state into islands via OOB swaps. Islands can submit data to the server via " (code "sx-post") ".") - (li (strong "Spec-first.") " Signal semantics live in " (code "signals.sx") ". Bootstrapped to JS and Python. The same primitives will work in future hosts — Go, Rust, native.") - (li (strong "No build step.") " Reactive bindings are created at runtime during DOM rendering. No JSX compilation, no Babel transforms, no Vite plugins. This is slower than SolidJS's compiled output. For islands — small, focused interactive regions — it does not matter.")) - - (p :class "mt-4" "The recommendation from the " (a :href "/essays/client-reactivity" :class "text-violet-700 underline" "Client Reactivity") " essay was: \"Tier 4 probably never.\" This plan is what happens when the answer changes. The design avoids every footgun that essay warns about — no useState cascading to useEffect cascading to Context cascading to a state management library. Signals are one primitive. Islands are one boundary. The rest is composition.")))) diff --git a/sx/sx/reactive-islands.sx b/sx/sx/reactive-islands.sx index 0b83bb7..1e514d3 100644 --- a/sx/sx/reactive-islands.sx +++ b/sx/sx/reactive-islands.sx @@ -101,14 +101,18 @@ (td :class "px-3 py-2 text-stone-700" "Test suite") (td :class "px-3 py-2 text-green-700 font-medium" "17/17") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "test-signals.sx")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Named stores (L3)") + (td :class "px-3 py-2 text-green-700 font-medium" "Spec'd") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx: def-store, use-store, clear-stores")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Event bridge") + (td :class "px-3 py-2 text-green-700 font-medium" "Spec'd") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx: emit-event, on-event, bridge-event")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Client hydration") (td :class "px-3 py-2 text-amber-600 font-medium" "TODO") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "boot.sx")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Connected islands (L3)") - (td :class "px-3 py-2 text-amber-600 font-medium" "TODO") - (td :class "px-3 py-2 font-mono text-xs text-stone-500" "def-store, use-store")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Event bindings") (td :class "px-3 py-2 text-amber-600 font-medium" "TODO") @@ -165,6 +169,243 @@ (ul :class "space-y-2 text-stone-600 list-disc pl-5" (li (strong "Client hydration") " — " (code "boot.sx") " discovers " (code "data-sx-island") " elements, creates signals from " (code "data-sx-state") ", re-renders in reactive context") (li (strong "Event bindings") " — wire " (code ":on-click (fn (e) ...)") " inside islands to DOM event listeners") - (li (strong "Connected islands (L3)") " — " (code "def-store") " / " (code "use-store") " for named stores shared between distant islands") (li (strong "Keyed list reconciliation") " — " (code "reactive-list") " currently clears and re-renders; needs keyed morph for efficient updates")) - (p "See the " (a :href "/plans/reactive-islands" :sx-get "/plans/reactive-islands" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "full plan") " for the complete design document.")))) + (p "See the " (a :href "/reactive-islands/plan" :sx-get "/reactive-islands/plan" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "full plan") " for the complete design document.")))) + + +;; --------------------------------------------------------------------------- +;; Event Bridge — DOM events for lake→island communication +;; --------------------------------------------------------------------------- + +(defcomp ~reactive-islands-event-bridge-content () + (~doc-page :title "Event Bridge" + + (~doc-section :title "The Problem" :id "problem" + (p "A reactive island can contain server-rendered content — an htmx \"lake\" that swaps via " (code "sx-get") "/" (code "sx-post") ". The lake content is pure HTML from the server. It has no access to island signals.") + (p "But sometimes the lake needs to " (em "tell") " the island something happened. A server-rendered \"Add to Cart\" button needs to update the island's cart signal. A server-rendered search form needs to feed results into the island's result signal.") + (p "The event bridge solves this: DOM custom events bubble from the lake up to the island, where an effect listens and updates signals.")) + + (~doc-section :title "How it works" :id "how" + (p "Three components:") + (ol :class "space-y-2 text-stone-600 list-decimal list-inside" + (li (strong "Server emits: ") "Server-rendered elements carry " (code "data-sx-emit") " attributes. When the user interacts, the client dispatches a CustomEvent.") + (li (strong "Event bubbles: ") "The event bubbles up through the DOM tree until it reaches the island container.") + (li (strong "Effect catches: ") "An effect inside the island listens for the event name and updates a signal.")) + + (~doc-code :code (highlight ";; Island with an event bridge\n(defisland ~product-page (&key product)\n (let ((cart-items (signal (list))))\n\n ;; Bridge: listen for \"cart:add\" events from server content\n (bridge-event container \"cart:add\" cart-items\n (fn (detail)\n (append (deref cart-items)\n (dict :id (get detail \"id\")\n :name (get detail \"name\")\n :price (get detail \"price\")))))\n\n (div\n ;; Island header with reactive cart count\n (div :class \"flex justify-between\"\n (h1 (get product \"name\"))\n (span :class \"badge\" (length (deref cart-items)) \" items\"))\n\n ;; htmx lake — server-rendered product details\n ;; This content is swapped by sx-get, not rendered by the island\n (div :id \"product-details\"\n :sx-get (str \"/products/\" (get product \"id\") \"/details\")\n :sx-swap \"innerHTML\"\n :sx-trigger \"load\"))))" "lisp")) + + (p "The server handler for " (code "/products/:id/details") " returns HTML with emit attributes:") + (~doc-code :code (highlight ";; Server-rendered response (pure HTML, no signals)\n(div\n (p (get product \"description\"))\n (div :class \"flex gap-2 mt-4\"\n (button\n :data-sx-emit \"cart:add\"\n :data-sx-emit-detail (json-serialize\n (dict :id (get product \"id\")\n :name (get product \"name\")\n :price (get product \"price\")))\n :class \"bg-violet-600 text-white px-4 py-2 rounded\"\n \"Add to Cart\")))" "lisp")) + (p "The button is plain server HTML. When clicked, the client's event bridge dispatches " (code "cart:add") " with the JSON detail. The island effect catches it and appends to " (code "cart-items") ". The badge updates reactively.")) + + (~doc-section :title "Why signals survive swaps" :id "survival" + (p "Signals live in JavaScript memory (closures), not in the DOM. When htmx swaps content inside an island:") + (ul :class "space-y-2 text-stone-600 list-disc pl-5" + (li (strong "Swap inside island: ") "Signals survive. The lake content is replaced but the island's signal closures are untouched. Effects re-bind to new DOM nodes if needed.") + (li (strong "Swap outside island: ") "Signals survive. The island is not affected by swaps to other parts of the page.") + (li (strong "Swap replaces island: ") "Signals are " (em "lost") ". The island is disposed. This is where " (a :href "/reactive-islands/named-stores" :sx-get "/reactive-islands/named-stores" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "named stores") " come in — they persist at page level, surviving island destruction."))) + + (~doc-section :title "Spec" :id "spec" + (p "The event bridge is spec'd in " (code "signals.sx") " (sections 12-13). Three functions:") + + (~doc-code :code (highlight ";; Low-level: dispatch a custom event\n(emit-event el \"cart:add\" {:id 42 :name \"Widget\"})\n\n;; Low-level: listen for a custom event\n(on-event container \"cart:add\" (fn (e)\n (swap! items (fn (old) (append old (event-detail e))))))\n\n;; High-level: bridge an event directly to a signal\n;; Creates an effect with automatic cleanup on dispose\n(bridge-event container \"cart:add\" items\n (fn (detail) (append (deref items) detail)))" "lisp")) + + (p "Platform interface required:") + (div :class "overflow-x-auto rounded border border-stone-200 mt-2" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Function") + (th :class "px-3 py-2 font-medium text-stone-600" "Description"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "(dom-listen el name handler)") + (td :class "px-3 py-2 text-stone-700" "Attach event listener, return remove function")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "(dom-dispatch el name detail)") + (td :class "px-3 py-2 text-stone-700" "Dispatch CustomEvent with detail, bubbles: true")) + (tr + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "(event-detail e)") + (td :class "px-3 py-2 text-stone-700" "Extract .detail from CustomEvent")))))))) + + +;; --------------------------------------------------------------------------- +;; Named Stores — page-level signal containers +;; --------------------------------------------------------------------------- + +(defcomp ~reactive-islands-named-stores-content () + (~doc-page :title "Named Stores" + + (~doc-section :title "The Problem" :id "problem" + (p "Islands are isolated by default. Signal props work when islands are adjacent, but not when they are:") + (ul :class "space-y-1 text-stone-600 list-disc pl-5" + (li "Distant in the DOM tree (header badge + drawer island)") + (li "Defined in different " (code ".sx") " files") + (li "Destroyed and recreated by htmx swaps")) + (p "Named stores solve all three. A store is a named collection of signals that lives at " (em "page") " scope, not island scope.")) + + (~doc-section :title "def-store / use-store" :id "api" + (~doc-code :code (highlight ";; Create a named store — called once at page level\n;; The init function creates signals and computeds\n(def-store \"cart\" (fn ()\n (let ((items (signal (list))))\n (dict\n :items items\n :count (computed (fn () (length (deref items))))\n :total (computed (fn () (reduce + 0\n (map (fn (i) (get i \"price\")) (deref items)))))))))\n\n;; Use the store from any island — returns the signal dict\n(defisland ~cart-badge ()\n (let ((store (use-store \"cart\")))\n (span :class \"badge bg-violet-100 text-violet-800 px-2 py-1 rounded-full\"\n (deref (get store \"count\")))))\n\n(defisland ~cart-drawer ()\n (let ((store (use-store \"cart\")))\n (div :class \"p-4\"\n (h2 \"Cart\")\n (ul (map (fn (item)\n (li :class \"flex justify-between py-1\"\n (span (get item \"name\"))\n (span :class \"text-stone-500\" \"\\u00A3\" (get item \"price\"))))\n (deref (get store \"items\"))))\n (div :class \"border-t pt-2 font-semibold\"\n \"Total: \\u00A3\" (deref (get store \"total\"))))))" "lisp")) + + (p (code "def-store") " is " (strong "idempotent") " — calling it again with the same name returns the existing store. This means multiple components can call " (code "def-store") " defensively without double-creating.")) + + (~doc-section :title "Lifecycle" :id "lifecycle" + (ol :class "space-y-2 text-stone-600 list-decimal list-inside" + (li (strong "Page load: ") (code "def-store") " creates the store in a global registry. Signals are initialized.") + (li (strong "Island hydration: ") "Each island calls " (code "use-store") " to get the shared signal dict. Derefs create subscriptions.") + (li (strong "Island swap: ") "An island is destroyed by htmx swap. Its effects are cleaned up. But the store " (em "persists") " — it's in the page-level registry, not the island scope.") + (li (strong "Island recreation: ") "The new island calls " (code "use-store") " again. Gets the same signals. Reconnects reactively. User state is preserved.") + (li (strong "Full page navigation: ") (code "clear-stores") " wipes the registry. Clean slate."))) + + (~doc-section :title "Combining with event bridge" :id "combined" + (p "Named stores + event bridge = full lake→island→island communication:") + + (~doc-code :code (highlight ";; Store persists across island lifecycle\n(def-store \"cart\" (fn () ...))\n\n;; Island 1: product page with htmx lake\n(defisland ~product-island ()\n (let ((store (use-store \"cart\")))\n ;; Bridge server-rendered \"Add\" buttons to store\n (bridge-event container \"cart:add\" (get store \"items\")\n (fn (detail) (append (deref (get store \"items\")) detail)))\n ;; Lake content swapped via sx-get\n (div :id \"product-content\" :sx-get \"/products/featured\")))\n\n;; Island 2: cart badge in header (distant in DOM)\n(defisland ~cart-badge ()\n (let ((store (use-store \"cart\")))\n (span (deref (get store \"count\")))))" "lisp")) + + (p "User clicks \"Add to Cart\" in server-rendered product content. " (code "cart:add") " event fires. Product island catches it via bridge. Store's " (code "items") " signal updates. Cart badge — in a completely different island — updates reactively because it reads the same signal.")) + + (~doc-section :title "Spec" :id "spec" + (p "Named stores are spec'd in " (code "signals.sx") " (section 12). Three functions:") + + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Function") + (th :class "px-3 py-2 font-medium text-stone-600" "Description"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "(def-store name init-fn)") + (td :class "px-3 py-2 text-stone-700" "Create or return existing named store. init-fn returns a dict of signals/computeds.")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "(use-store name)") + (td :class "px-3 py-2 text-stone-700" "Get existing store by name. Errors if store doesn't exist.")) + (tr + (td :class "px-3 py-2 font-mono text-sm text-violet-700" "(clear-stores)") + (td :class "px-3 py-2 text-stone-700" "Wipe all stores. Called on full page navigation.")))))))) + + +;; --------------------------------------------------------------------------- +;; Plan — the full design document (moved from plans section) +;; --------------------------------------------------------------------------- + +(defcomp ~reactive-islands-plan-content () + (~doc-page :title "Reactive Islands Plan" + + (~doc-section :title "Context" :id "context" + (p "SX already has a sliding bar for " (em "where") " rendering happens — server-side HTML, SX wire format for client rendering, or any point between. This is the isomorphism bar. It controls the render boundary.") + (p "There is a second bar, orthogonal to the first: " (em "how state flows.") " On one end, all state lives on the server — every user action is a round-trip, every UI update is a fresh render. This is the htmx model. On the other end, state lives on the client — signals, subscriptions, fine-grained DOM updates without server involvement. This is the React model.") + (p "These two bars are independent. You can have server-rendered HTML with client state (SSR + hydrated React). You can have client-rendered components with server state (current SX). The combination creates four quadrants:") + + (div :class "overflow-x-auto mt-4 mb-4" + (table :class "w-full text-sm text-left" + (thead + (tr :class "border-b border-stone-200" + (th :class "py-2 px-3 font-semibold text-stone-700" "") + (th :class "py-2 px-3 font-semibold text-stone-700" "Server State") + (th :class "py-2 px-3 font-semibold text-stone-700" "Client State"))) + (tbody :class "text-stone-600" + (tr :class "border-b border-stone-100" + (td :class "py-2 px-3 font-semibold text-stone-700" "Server Rendering") + (td :class "py-2 px-3" "Pure hypermedia (htmx)") + (td :class "py-2 px-3" "SSR + hydrated islands (Next.js)")) + (tr :class "border-b border-stone-100" + (td :class "py-2 px-3 font-semibold text-stone-700" "Client Rendering") + (td :class "py-2 px-3" "SX wire format (current)") + (td :class "py-2 px-3 font-semibold text-violet-700" "Reactive islands (this plan)"))))) + + (p "Today SX occupies the bottom-left quadrant — client-rendered components with server state. This plan adds the bottom-right: " (strong "reactive islands") " with client-local signals. A page can mix all four quadrants. Most content stays hypermedia. Interactive regions opt into reactivity. The author controls where each component sits on both bars.")) + + (~doc-section :title "The Spectrum" :id "spectrum" + (p "Four levels of client interactivity. Each is independently valuable. Each is opt-in per component.") + + (~doc-subsection :title "Level 0: Pure Hypermedia" + (p "The default. " (code "sx-get") ", " (code "sx-post") ", " (code "sx-swap") ". Server renders everything. Client swaps fragments. No client state. No JavaScript state management. This is where 90% of a typical application should live.")) + + (~doc-subsection :title "Level 1: Local DOM Operations" + (p "Imperative escape hatches for micro-interactions too small for a server round-trip: toggling a menu, switching a tab, showing a tooltip. " (code "toggle!") ", " (code "set-attr!") ", " (code "on-event") ". No reactive graph. Just do the thing directly.")) + + (~doc-subsection :title "Level 2: Reactive Islands" + (p (code "defisland") " components with local signals. Fine-grained DOM updates — no virtual DOM, no diffing, no component re-renders. A signal change updates only the DOM nodes that read it. Islands are isolated by default. The server can render their initial state.")) + + (~doc-subsection :title "Level 3: Connected Islands" + (p "Islands that share state via signal props or named stores (" (code "def-store") " / " (code "use-store") "). Plus event bridges for htmx lake-to-island communication. This is where SX starts to feel like React — but only in the regions that need it. The surrounding page remains hypermedia."))) + + (~doc-section :title "htmx Lakes" :id "lakes" + (p "An htmx lake is server-driven content " (em "inside") " a reactive island. The island provides the reactive boundary; the lake content is swapped via " (code "sx-get") "/" (code "sx-post") " like normal hypermedia.") + (p "This works because signals live in JavaScript closures, not in the DOM. When a swap replaces lake content, the island's signals are unaffected. The lake can communicate back to the island via the " (a :href "/reactive-islands/event-bridge" :sx-get "/reactive-islands/event-bridge" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "event bridge") ".") + + (~doc-subsection :title "Navigation scenarios" + (div :class "space-y-3" + (div :class "rounded border border-green-200 bg-green-50 p-3" + (div :class "font-semibold text-green-800" "Swap inside island") + (p :class "text-sm text-stone-600 mt-1" "Lake content replaced. Signals survive. Effects can rebind to new DOM. User state intact.")) + (div :class "rounded border border-green-200 bg-green-50 p-3" + (div :class "font-semibold text-green-800" "Swap outside island") + (p :class "text-sm text-stone-600 mt-1" "Different part of page updated. Island completely unaffected. User state intact.")) + (div :class "rounded border border-amber-200 bg-amber-50 p-3" + (div :class "font-semibold text-amber-800" "Swap replaces island") + (p :class "text-sm text-stone-600 mt-1" "Island disposed. Local signals lost. Named stores persist — new island reconnects via use-store.")) + (div :class "rounded border border-stone-200 p-3" + (div :class "font-semibold text-stone-800" "Full page navigation") + (p :class "text-sm text-stone-600 mt-1" "Everything cleared. clean slate. clear-stores wipes the registry."))))) + + (~doc-section :title "Reactive DOM Rendering" :id "reactive-rendering" + (p "The existing " (code "renderDOM") " function walks the AST and creates DOM nodes. Inside an island, it becomes signal-aware:") + + (~doc-subsection :title "Text bindings" + (~doc-code :code (highlight ";; (span (deref count)) creates:\n;; const text = document.createTextNode(sig.value)\n;; effect(() => text.nodeValue = sig.value)" "lisp")) + (p "Only the text node updates. The span is untouched.")) + + (~doc-subsection :title "Attribute bindings" + (~doc-code :code (highlight ";; (div :class (str \"panel \" (if (deref open?) \"visible\" \"hidden\")))\n;; effect(() => div.className = ...)" "lisp"))) + + (~doc-subsection :title "Conditional fragments" + (~doc-code :code (highlight ";; (when (deref show?) (~details)) creates:\n;; A marker comment node, then:\n;; effect(() => show ? insert-after(marker, render(~details)) : remove)" "lisp")) + (p "Equivalent to SolidJS's " (code "Show") " — but falls out naturally from the evaluator.")) + + (~doc-subsection :title "List rendering" + (~doc-code :code (highlight "(map (fn (item) (li :key (get item \"id\") (get item \"name\")))\n (deref items))" "lisp")) + (p "Keyed elements are reused and reordered. Unkeyed elements are morphed."))) + + (~doc-section :title "Remaining Work" :id "remaining" + (div :class "overflow-x-auto rounded border border-stone-200" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Task") + (th :class "px-3 py-2 font-medium text-stone-600" "File") + (th :class "px-3 py-2 font-medium text-stone-600" "Description"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Client hydration") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "boot.sx") + (td :class "px-3 py-2 text-stone-700" "Discover data-sx-island elements, create signals from data-sx-state, re-render in reactive context")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Event bindings") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx") + (td :class "px-3 py-2 text-stone-700" "Wire :on-click (fn (e) ...) inside islands to DOM addEventListener")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "data-sx-emit processing") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "orchestration.sx") + (td :class "px-3 py-2 text-stone-700" "Auto-dispatch custom events from data-sx-emit attributes on click")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Store bootstrapping") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "bootstrap_js.py") + (td :class "px-3 py-2 text-stone-700" "Transpile def-store, use-store, clear-stores, bridge-event to JS")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Keyed list reconciliation") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx") + (td :class "px-3 py-2 text-stone-700" "Keyed morph for reactive-list instead of clear + re-render")) + (tr + (td :class "px-3 py-2 text-stone-700" "Navigation integration") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "orchestration.sx") + (td :class "px-3 py-2 text-stone-700" "Call clear-stores on full page navigation, preserve stores on partial swaps")))))) + + (~doc-section :title "Design Principles" :id "principles" + (ol :class "space-y-3 text-stone-600 list-decimal list-inside" + (li (strong "Islands are opt-in.") " " (code "defcomp") " remains the default. Components are inert unless you choose " (code "defisland") ". No reactive overhead for static content.") + (li (strong "Signals are values, not hooks.") " Create them anywhere. Pass them as arguments. Store them in dicts. No rules about calling order or conditional creation.") + (li (strong "Fine-grained, not component-grained.") " A signal change updates the specific DOM node that reads it. The island does not re-render. There is no virtual DOM and no diffing beyond the morph algorithm already in SxEngine.") + (li (strong "The server is still the authority.") " Islands handle client interactions. The server handles auth, data, routing. The server can push state into islands via OOB swaps. Islands can submit data to the server via " (code "sx-post") ".") + (li (strong "Spec-first.") " Signal semantics live in " (code "signals.sx") ". Bootstrapped to JS and Python. The same primitives will work in future hosts — Go, Rust, native.") + (li (strong "No build step.") " Reactive bindings are created at runtime during DOM rendering. No JSX compilation, no Babel transforms, no Vite plugins.")) + + (p :class "mt-4" "The recommendation from the " (a :href "/essays/client-reactivity" :class "text-violet-700 underline" "Client Reactivity") " essay was: \"Tier 4 probably never.\" This plan is what happens when the answer changes. The design avoids every footgun that essay warns about — no useState cascading to useEffect cascading to Context cascading to a state management library. Signals are one primitive. Islands are one boundary. The rest is composition.")))) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 8ce139f..d1b3099 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -605,7 +605,6 @@ "social-sharing" (~plan-social-sharing-content) "sx-ci" (~plan-sx-ci-content) "live-streaming" (~plan-live-streaming-content) - "reactive-islands" (~plan-reactive-islands-content) :else (~plans-index-content))) ;; --------------------------------------------------------------------------- @@ -635,6 +634,9 @@ :selected (or (find-current reactive-islands-nav-items slug) "")) :content (case slug "demo" (~reactive-islands-demo-content) + "event-bridge" (~reactive-islands-event-bridge-content) + "named-stores" (~reactive-islands-named-stores-content) + "plan" (~reactive-islands-plan-content) :else (~reactive-islands-index-content))) ;; ---------------------------------------------------------------------------