diff --git a/hosts/javascript/bootstrap.py b/hosts/javascript/bootstrap.py index 82f3d4d5..770df04e 100644 --- a/hosts/javascript/bootstrap.py +++ b/hosts/javascript/bootstrap.py @@ -130,7 +130,7 @@ def compile_ref_to_js( ("render.sx", "render (core)"), ("web-forms.sx", "web-forms (defstyle, deftype, defeffect, defrelation)"), ] - for name in ("parser", "html", "sx", "dom", "engine", "orchestration", "boot"): + for name in ("parser", "html", "sx", "dom-lib", "browser-lib", "dom", "engine", "orchestration", "boot"): if name in adapter_set: sx_files.append(ADAPTER_FILES[name]) # Use explicit ordering for spec modules (respects dependencies) diff --git a/hosts/ocaml/bin/debug_set.ml b/hosts/ocaml/bin/debug_set.ml index 2f68b4ba..9cd67d3d 100644 --- a/hosts/ocaml/bin/debug_set.ml +++ b/hosts/ocaml/bin/debug_set.ml @@ -1,6 +1,6 @@ -module T = Sx.Sx_types -module P = Sx.Sx_parser -module R = Sx.Sx_ref +module T = Sx_types +module P = Sx_parser +module R = Sx_ref open T let () = diff --git a/hosts/ocaml/lib/dune b/hosts/ocaml/lib/dune index be36fed9..15bfaf51 100644 --- a/hosts/ocaml/lib/dune +++ b/hosts/ocaml/lib/dune @@ -1,2 +1,3 @@ (library - (name sx)) + (name sx) + (wrapped false)) diff --git a/sx/sx/plans/sx-web.sx b/sx/sx/plans/sx-web.sx new file mode 100644 index 00000000..fd92941b --- /dev/null +++ b/sx/sx/plans/sx-web.sx @@ -0,0 +1,337 @@ +;; sx-web: Federated Component Web +;; Browser-native peer network with content-addressed SX components. +;; Builds on sx-pub (phases 1-4 complete: publishing, federation, anchoring, IPFS). + +(defcomp ~plans/sx-web/plan-sx-web-content () + (~docs/page :title "sx-web: Federated Component Web" + + (p :class "text-stone-500 text-sm italic mb-8" + "A two-tier peer network where server nodes pin to IPFS and browser tabs " + "participate via WebTransport. Components, tests, and content are all " + "content-addressed SX expressions verified by CID regardless of source. " + "Builds on sx-pub phases 1–4 (publishing, federation, anchoring, IPFS).") + + ;; ===================================================================== + ;; Architecture + ;; ===================================================================== + + (~docs/section :title "Architecture" :id "architecture" + + (p "Two tiers, one logical network. Trust anchored on CIDs, not sources.") + + (table :class "w-full mb-6 text-sm" + (thead + (tr (th :class "text-left p-2" "") (th :class "text-left p-2" "Server Node") (th :class "text-left p-2" "Browser Node"))) + (tbody + (tr (td :class "p-2 font-medium" "Runtime") (td :class "p-2" "OCaml + Python") (td :class "p-2" "JavaScript (sx-browser.js)")) + (tr (td :class "p-2 font-medium" "Storage") (td :class "p-2" "IPFS daemon + PostgreSQL") (td :class "p-2" "IndexedDB + Cache API")) + (tr (td :class "p-2 font-medium" "IPFS") (td :class "p-2" "Full DHT peer, pins content") (td :class "p-2" "Verifies CIDs, caches locally")) + (tr (td :class "p-2 font-medium" "Transport") (td :class "p-2" "HTTP/3 + WebTransport server") (td :class "p-2" "WebTransport client")) + (tr (td :class "p-2 font-medium" "Availability") (td :class "p-2" "Long-lived, always on") (td :class "p-2" "Ephemeral, online when open")) + (tr (td :class "p-2 font-medium" "Trust") (td :class "p-2" "CID verification + blockchain anchor") (td :class "p-2" "CID verification (same guarantee)")))) + + (p "A component fetched from IPFS, from a server, from another browser tab, " + "or from local IndexedDB cache is verified the same way: hash it, check the CID. " + "The transport is irrelevant to the trust model.")) + + ;; ===================================================================== + ;; Phase 1: Browser Node Foundation + ;; ===================================================================== + + (~docs/section :title "Phase 1: Browser Node Foundation" :id "phase-1" + + (p :class "text-stone-600 italic mb-4" + "Make the browser a first-class participant, not just a viewer.") + + (~docs/subsection :title "1a. Content Store (IndexedDB)" + (p "Browser-local content-addressed store. Same interface as server-side " + (code "content-put") "/" (code "content-get") " but backed by IndexedDB.") + + (~docs/code :src (highlight + ";; Browser content store — pure SX, platform provides IndexedDB ops\n(define browser-store (make-content-store\n {:backend :indexeddb\n :db-name \"sx-web\"\n :verify true})) ;; always verify CID on read\n\n;; Same API as server store\n(content-put browser-store sx-source) ;; → CID\n(content-get browser-store cid) ;; → SX source | nil\n(content-has? browser-store cid) ;; → bool\n(content-pin browser-store cid) ;; mark as persistent\n(content-unpin browser-store cid) ;; allow eviction" + "lisp")) + + (p "Two IndexedDB object stores:") + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li (strong "blobs") " — CID → SX source bytes (content-addressed, immutable)") + (li (strong "pins") " — CID → {pinned, last-accessed, size, source-node} (eviction metadata)")) + + (p "Unpinned content evicted LRU when storage exceeds quota. " + "Pinned content survives until explicitly unpinned.")) + + (~docs/subsection :title "1b. Service Worker" + (p "Intercepts fetch requests to resolve CIDs locally before hitting the network.") + + (~docs/code :src (highlight + ";; Service Worker routes\n;;\n;; /sx-web/cid/ → check IndexedDB first, then fetch from home node\n;; /sx-web/resolve/ → resolve path→CID via local index, then fetch\n;; /sx-web/eval/ → fetch + evaluate, return rendered result\n;;\n;; Cache strategy:\n;; CID routes are immutable — cache forever once verified\n;; Path routes check freshness against home node" + "text")) + + (p "The Service Worker makes the browser work offline for all pinned components. " + "Online, it deduplicates fetches across tabs — one IndexedDB, shared cache.")) + + (~docs/subsection :title "1c. CID Verification in JavaScript" + (p "SHA3-256 hashing in the browser for CID verification. " + "Uses SubtleCrypto API (hardware-accelerated on modern browsers).") + + (~docs/code :src (highlight + ";; New platform primitive for browser host\n(define content-hash\n (fn (source)\n (host-call (host-get (host-global \"crypto\") \"subtle\")\n \"digest\" \"SHA-256\" (encode-utf8 source))))\n\n;; Verification: hash content, compare to claimed CID\n(define verify-cid\n (fn (cid source)\n (= cid (content-hash source))))" + "lisp")) + + (p "One new platform primitive: " (code "content-hash") ". " + "Everything else (verification, pinning, eviction) is pure SX.")) + + (~docs/subsection :title "1d. Deliverables" + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li (code "web/lib/content-store.sx") " — browser content store (~120 LOC)") + (li (code "web/lib/sw-routes.sx") " — Service Worker CID routing (~80 LOC)") + (li "Platform primitive: " (code "content-hash") " in JS host (~15 LOC)") + (li "IndexedDB schema: blobs + pins stores")))) + + ;; ===================================================================== + ;; Phase 2: WebTransport Peer Layer + ;; ===================================================================== + + (~docs/section :title "Phase 2: WebTransport Peer Layer" :id "phase-2" + + (p :class "text-stone-600 italic mb-4" + "Bidirectional streams between browsers and servers. The protocol is SX.") + + (~docs/subsection :title "2a. SX Protocol Definition" + (p "The protocol is an SX component. Nodes fetch, evaluate, and speak it.") + + (~docs/code :src (highlight + "(defprotocol sx-sync \"0.1\"\n :messages {\n ;; Content exchange\n :have (fn (cids) ...) ;; I have these CIDs\n :want (fn (cids) ...) ;; I need these CIDs\n :provide (fn (cid source) ...) ;; Here's the content\n :reject (fn (cid reason) ...) ;; Can't provide\n\n ;; Discovery\n :announce (fn (cid metadata) ...) ;; New content published\n :query (fn (pattern) ...) ;; Search for components\n :results (fn (matches) ...) ;; Search results\n\n ;; Identity\n :hello (fn (node-id pubkey) ...) ;; Handshake\n :verify (fn (challenge sig) ...) ;; Prove identity\n }\n\n :flow {\n ;; Content resolution\n :want → :provide | :reject\n ;; Publishing\n :announce → :want (from interested peers)\n ;; Connection\n :hello → :verify → ready\n })" + "lisp")) + + (p "The protocol definition is itself content-addressed. Nodes pin the protocol " + "version they speak. Protocol upgrades are published to sx-pub like any component.")) + + (~docs/subsection :title "2b. WebTransport Server (Server Nodes)" + (p "HTTP/3 WebTransport endpoint on server nodes. " + "Browsers connect and exchange SX messages over bidirectional streams.") + + (~docs/code :src (highlight + ";; Server-side: WebTransport handler\n;;\n;; One stream per conversation:\n;; Browser → Server: (want (list \"Qm..abc\" \"Qm..def\"))\n;; Server → Browser: (provide \"Qm..abc\" \"(defcomp ...)\")\n;; Server → Browser: (provide \"Qm..def\" \"(defisland ...)\")\n;;\n;; Push notifications:\n;; Server → Browser: (announce \"Qm..xyz\" {:author \"...\" :type :component})\n;;\n;; Peer introduction:\n;; Server → Browser: (peers (list {:id \"...\" :transport \"wt://...\"}))" + "text")) + + (p "Server nodes also relay between browsers that can't connect directly. " + "A browser publishes through its home node, which delivers to followers " + "over existing sx-pub federation " (em "and") " pushes to connected browser peers.")) + + (~docs/subsection :title "2c. WebTransport Client (Browser Nodes)" + (p "Browser connects to home node on page load. Receives push updates. " + "Can also connect to peer nodes for direct exchange.") + + (~docs/code :src (highlight + ";; Browser-side: WebTransport client\n(define sx-peer\n (fn (url)\n (let ((wt (host-call (host-global \"WebTransport\") \"new\" url))\n (ready (host-get wt \"ready\")))\n (promise-then ready\n (fn ()\n ;; Send hello with identity\n (peer-send wt (sx-hello (node-id) (node-pubkey)))\n ;; Listen for incoming messages\n (peer-listen wt (fn (msg)\n (dispatch-protocol msg))))))))\n\n;; Connect to home node on boot\n(sx-peer (str \"https://\" home-node \"/sx-web/wt\"))" + "lisp")) + + (p "Three new platform primitives for the browser host:") + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li (code "wt-connect") " — open WebTransport session") + (li (code "wt-send") " — send SX expression on stream") + (li (code "wt-listen") " — register handler for incoming messages"))) + + (~docs/subsection :title "2d. Deliverables" + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li (code "web/lib/sx-sync.sx") " — protocol definition (~200 LOC)") + (li (code "web/lib/peer.sx") " — browser peer client (~150 LOC)") + (li "Server: WebTransport endpoint in Quart/Hypercorn (~200 LOC Python)") + (li "Platform primitives: " (code "wt-connect, wt-send, wt-listen") " (~50 LOC JS)") + (li "Peer relay logic on server nodes (~100 LOC Python)")))) + + ;; ===================================================================== + ;; Phase 3: In-Browser Authoring + ;; ===================================================================== + + (~docs/section :title "Phase 3: In-Browser Authoring" :id "phase-3" + + (p :class "text-stone-600 italic mb-4" + "Edit, preview, test, and publish from a browser tab. No local dev environment.") + + (~docs/subsection :title "3a. Live Editor" + (p "SX editor component with real-time preview. " + "Edit source on the left, rendered output on the right. " + "The editor is itself an SX island.") + + (~docs/code :src (highlight + "(defisland ~sx-web/editor (&key initial-source)\n (let ((source (signal (or initial-source \"\")))\n (parsed (computed (fn () (try-parse (deref source)))))\n (preview (computed (fn () (try-render (deref parsed))))))\n (div :class \"flex h-full\"\n ;; Source editor (textarea or CodeMirror via foreign FFI)\n (div :class \"flex-1\"\n (textarea :bind source :class \"font-mono ...\"))\n ;; Live preview\n (div :class \"flex-1 border-l\"\n (if (get (deref preview) \"error\")\n (div :class \"text-red-600\" (get (deref preview) \"error\"))\n (div (get (deref preview) \"result\")))))))" + "lisp")) + + (p "The evaluator runs client-side. No server round-trip for preview. " + "Syntax errors, type errors, and render errors show inline as you type.")) + + (~docs/subsection :title "3b. In-Browser Test Runner" + (p "Tests are SX expressions. The browser evaluator runs them directly.") + + (~docs/code :src (highlight + ";; Tests travel with components\n(deftest ~my-component/tests\n (test \"renders title\"\n (let ((html (render-to-html (~my-component :title \"Hello\"))))\n (assert-contains html \"Hello\")))\n\n (test \"handles empty props\"\n (let ((html (render-to-html (~my-component))))\n (assert-contains html \"Untitled\"))))\n\n;; Run in browser — same evaluator, same results\n(run-tests ~my-component/tests)\n;; => {:passed 2 :failed 0 :errors ()}" + "lisp")) + + (p "Component + tests + docs are published as a single unit to sx-pub. " + "Any node can re-run the tests to verify the component works.")) + + (~docs/subsection :title "3c. Publish Flow" + (p "From browser, one action: edit → test → publish → federate.") + + (~docs/code :src (highlight + ";; Browser publish flow\n(define publish!\n (fn (source)\n (let ((cid (content-hash source))\n (tests (extract-tests source))\n (results (run-tests tests)))\n (when (all-passed? results)\n ;; Pin locally\n (content-pin browser-store cid)\n ;; Push to home node via WebTransport\n (peer-send home-peer\n (sx-publish cid source\n {:tests results\n :requires (extract-requires source)}))\n ;; Home node pins to IPFS + anchors + federates\n cid))))" + "lisp")) + + (p "The browser handles editing, testing, hashing, and local pinning. " + "The home server handles IPFS pinning, blockchain anchoring, and federation delivery. " + "Clean split: browsers create, servers distribute.")) + + (~docs/subsection :title "3d. Deliverables" + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li (code "web/lib/editor.sx") " — live editor island (~300 LOC)") + (li (code "web/lib/test-runner.sx") " — browser test runner (~150 LOC)") + (li (code "web/lib/publish.sx") " — publish flow (~100 LOC)") + (li "Test extraction from SX source (find deftest forms)")))) + + ;; ===================================================================== + ;; Phase 4: Component Discovery & Resolution + ;; ===================================================================== + + (~docs/section :title "Phase 4: Component Discovery & Resolution" :id "phase-4" + + (p :class "text-stone-600 italic mb-4" + "Find, resolve, and depend on components across the federation.") + + (~docs/subsection :title "4a. Dependency Resolution" + (p "Components declare dependencies via " (code ":requires") ". " + "Resolution checks local cache first, then home node, then federation.") + + (~docs/code :src (highlight + ";; Component with declared dependencies\n(defcomp ~charts/bar-chart (&key data labels)\n :requires ((~cssx/tw \"Qm..css\") ;; pinned by CID\n (~charts/axis \"Qm..axis\") ;; specific version\n (~charts/tooltip :latest)) ;; resolve latest from publisher\n ...)\n\n;; Resolution order:\n;; 1. Local IndexedDB (by CID)\n;; 2. Service Worker cache\n;; 3. Home node (WebTransport :want)\n;; 4. Federation query (WebTransport :query)\n;; 5. IPFS gateway (HTTP fallback)" + "lisp")) + + (p "CID dependencies are immutable — resolve once, cache forever. " + (code ":latest") " dependencies check the publisher's feed for the current CID.")) + + (~docs/subsection :title "4b. Component Browser" + (p "Browse, search, and preview components from the federated network. " + "Each component renders its own preview because it IS executable SX.") + + (~docs/code :src (highlight + ";; Search the federation\n(peer-send home-peer\n (sx-query {:type :component\n :tags [\"chart\" \"visualization\"]\n :has-tests true}))\n\n;; Results include CID, metadata, AND the component itself\n;; Click to preview — evaluates client-side\n;; Click to pin — stores in IndexedDB\n;; Click to fork — opens in editor with source" + "lisp")) + + (p "The component browser is itself an SX component published to sx-pub.")) + + (~docs/subsection :title "4c. Deliverables" + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li (code "web/lib/resolver.sx") " — dependency resolution chain (~200 LOC)") + (li (code "sx/sx/tools/component-browser.sx") " — federated browser (~400 LOC)") + (li (code "sx/sx/tools/search.sx") " — search interface (~150 LOC)") + (li "Server: search index over pinned components")))) + + ;; ===================================================================== + ;; Phase 5: Node Bootstrap + ;; ===================================================================== + + (~docs/section :title "Phase 5: Node Bootstrap" :id "phase-5" + + (p :class "text-stone-600 italic mb-4" + "Standing up a new node: one command, minutes to first render.") + + (~docs/subsection :title "5a. Server Node Bootstrap" + (~docs/code :src (highlight + "# One command. Pulls Docker image, starts all services.\ndocker run -d --name sx-node \\\n -e SX_SEED_PEER=pub.sx-web.org \\\n -e SX_DOMAIN=my-node.example.com \\\n -p 443:443 \\\n ghcr.io/rose-ash/sx-node:latest\n\n# What happens:\n# 1. IPFS daemon starts, connects to network\n# 2. Fetches spec CIDs from seed peer\n# 3. Evaluates specs — now has a working SX evaluator\n# 4. Follows seed peer — receives component announcements\n# 5. Caddy auto-provisions TLS via Let's Encrypt\n# 6. WebTransport endpoint live\n# 7. Ready to serve, publish, and federate" + "bash")) + + (p "The bootstrap IS the proof: if your node can fetch specs from the network, " + "evaluate them, and render components — the system works. " + "No manual configuration beyond domain name and seed peer.")) + + (~docs/subsection :title "5b. Browser Node Bootstrap" + (~docs/code :src (highlight + ";; Visit any sx-web node in a browser\n;; → sx-browser.js loads (evaluator + signals + DOM adapter)\n;; → Service Worker installs (CID caching)\n;; → WebTransport connects to host node\n;; → Spec CIDs pinned to IndexedDB\n;; → You're a node.\n;;\n;; No install. No signup. No CLI.\n;; Close the tab → ephemeral node disappears\n;; Reopen → reconnects, IndexedDB still has your pins" + "text")) + + (p "A browser becomes a node by visiting a URL. " + "Leaving keeps your pins. Returning restores your state. " + "The barrier to entry is typing an address.")) + + (~docs/subsection :title "5c. Deliverables" + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li "Docker image: " (code "sx-node") " with OCaml host, IPFS, Caddy, PostgreSQL") + (li "Bootstrap script: fetch specs, verify CIDs, initialize DB") + (li "Seed peer protocol: advertise available specs + popular components") + (li "Browser auto-bootstrap: Service Worker install + spec pinning on first visit")))) + + ;; ===================================================================== + ;; Phase 6: AI Composition Layer + ;; ===================================================================== + + (~docs/section :title "Phase 6: AI Composition Layer" :id "phase-6" + + (p :class "text-stone-600 italic mb-4" + "The federated graph is simultaneously training data, retrieval context, and composition target.") + + (~docs/subsection :title "6a. Component Retrieval" + (p "AI resolves components by CID, not by guessing. " + "The federation is a typed, tested, searchable context window.") + + (~docs/code :src (highlight + ";; AI composition query\n(ai-compose\n {:intent \"dashboard with sales chart and user table\"\n :constraints {:has-tests true\n :min-pins 10 ;; popularity signal\n :verified true} ;; blockchain-anchored\n :context (deref current-page)})\n\n;; AI doesn't generate from scratch — it assembles:\n;; 1. Search federation for matching components\n;; 2. Check tests pass for each candidate\n;; 3. Resolve dependencies\n;; 4. Compose into page, respecting :requires\n;; 5. Output is auditable: list of CIDs + composition logic" + "lisp")) + + (p "The output is a composition of existing, tested, content-addressed components. " + "Not generated code — assembled code. Deterministic, verifiable, traceable.")) + + (~docs/subsection :title "6b. Self-Improving Tooling" + (p "AI tools are SX components published to sx-pub. " + "They improve as the network grows — more components to compose from, " + "more tests to validate against, more usage patterns to learn from.") + + (p "The network effect works for both humans and AI:") + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li "Every component published is more material for AI composition") + (li "Every test attached is more validation for AI output") + (li "Every fork is a preference signal for AI ranking") + (li "Every pin is a popularity signal for AI retrieval"))) + + (~docs/subsection :title "6c. Deliverables" + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li (code "web/lib/ai-compose.sx") " — composition interface (~200 LOC)") + (li "Embedding index over component metadata + signatures") + (li "Retrieval API: search by intent, filter by quality signals") + (li "Composition validator: check output resolves, tests pass")))) + + ;; ===================================================================== + ;; Implementation Order + ;; ===================================================================== + + (~docs/section :title "Implementation Order" :id "order" + + (~docs/code :src (highlight + "Phase 1 Browser Node Foundation ~400 LOC SX + ~100 LOC JS\n IndexedDB store, Service Worker, CID verification\n Prerequisite: none (extends existing sx-browser.js)\n\nPhase 2 WebTransport Peer Layer ~550 LOC SX + ~250 LOC Python\n Protocol definition, server endpoint, browser client\n Prerequisite: Phase 1 (needs content store)\n\nPhase 3 In-Browser Authoring ~550 LOC SX\n Editor, test runner, publish flow\n Prerequisite: Phase 1 + 2 (needs store + peer connection)\n\nPhase 4 Component Discovery ~750 LOC SX\n Dependency resolution, component browser, search\n Prerequisite: Phase 2 (needs federation queries)\n\nPhase 5 Node Bootstrap ~300 LOC config + scripts\n Docker image, auto-bootstrap, seed protocol\n Prerequisite: Phase 1–4 (packages everything)\n\nPhase 6 AI Composition ~400 LOC SX + embedding index\n Retrieval, composition, validation\n Prerequisite: Phase 4 (needs searchable component graph)\n\n Total new SX: ~2650 LOC\n Total new platform: ~350 LOC (JS + Python)\n Timeline: Phases are independent workstreams after Phase 1" + "text")) + + (p "Phase 1 is the foundation — everything else depends on the browser being able to " + "store, verify, and cache content-addressed SX. After that, phases 2–4 can proceed " + "in parallel. Phase 5 packages it all. Phase 6 builds on the populated network.")) + + ;; ===================================================================== + ;; What Already Exists + ;; ===================================================================== + + (~docs/section :title "What Already Exists (sx-pub Phases 1–4)" :id "existing" + + (table :class "w-full mb-6 text-sm" + (thead + (tr (th :class "text-left p-2" "Capability") (th :class "text-left p-2" "Status") (th :class "text-left p-2" "Location"))) + (tbody + (tr (td :class "p-2" "IPFS async client") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "shared/utils/ipfs_client.py")) + (tr (td :class "p-2" "Merkle tree + proofs") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "shared/utils/anchoring.py")) + (tr (td :class "p-2" "OpenTimestamps anchoring") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "shared/utils/anchoring.py")) + (tr (td :class "p-2" "ActivityPub federation") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "shared/infrastructure/activitypub.py")) + (tr (td :class "p-2" "sx-pub HTTP handlers") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "sx/sx/handlers/pub-api.sx")) + (tr (td :class "p-2" "Publishing + CID resolution") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "sx/sxc/pages/pub_helpers.py")) + (tr (td :class "p-2" "SX wire format (aser)") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "web/adapter-sx.sx")) + (tr (td :class "p-2" "Content store (in-memory)") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "lib/content.sx")) + (tr (td :class "p-2" "Component localStorage cache") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "web/boot.sx")) + (tr (td :class "p-2" "Docker dev environment") (td :class "p-2 text-green-700" "Done") (td :class "p-2 font-mono text-xs" "docker-compose.dev-pub.yml")))) + + (p "The server-side infrastructure is complete. The plan above extends it to browsers.")))) diff --git a/sx/sx/reactive-runtime.sx b/sx/sx/reactive-runtime.sx index 01d377ca..49450056 100644 --- a/sx/sx/reactive-runtime.sx +++ b/sx/sx/reactive-runtime.sx @@ -108,6 +108,12 @@ "A non-reactive mutable cell for DOM handles, canvas contexts, and timer IDs. " "Like a signal but with no subscriber tracking and no notifications.") + (~docs/section :title "Live Demo" :id "demo" + (p "Two patterns that refs formalize: an interval ID captured in an effect closure, and a DOM element handle stored in a dict. Neither needs reactivity — they're infrastructure state.") + (~reactive-runtime/demo-ref) + (~docs/code :src (highlight "(defisland ~demo-ref ()\n (let ((input-ref (dict \"current\" nil))\n (ticks (signal 0))\n (running (signal false))\n (last-value (signal \"\")))\n ;; Timer ID captured in effect closure — the ref pattern\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! ticks inc)) 500)))\n (fn () (clear-interval id))))))\n ;; DOM handle in a dict — also the ref pattern\n (input :ref input-ref :type \"text\" ...)\n (button :on-click (fn (e)\n (reset! last-value\n (dom-get-prop (get input-ref \"current\") \"value\")))\n \"Read\")))" "lisp")) + (p "The timer ID lives in a closure — nothing reads it reactively. The DOM element is a " (code "(dict \"current\" nil)") " set by " (code ":ref") ". The " (code "ref") " function formalizes both into a single primitive with auto-disposal.")) + (~docs/section :title "API" :id "ref-api" (~docs/code :src (highlight @@ -148,6 +154,12 @@ "Clean boundary for calling host APIs — Canvas, WebGL, WebAudio, any browser API — " "from SX code. Function factories with automatic kebab-to-camelCase conversion.") + (~docs/section :title "Live Demo" :id "demo" + (p "Click the canvas to place colored rectangles. Uses " (code "host-call") " for canvas 2D context method calls and " (code "host-set!") " for property writes — the raw primitives that " (code "foreign-method") " and " (code "foreign-prop-setter") " will wrap.") + (~reactive-runtime/demo-foreign) + (~docs/code :src (highlight "(defisland ~demo-foreign ()\n (let ((canvas-ref (dict \"current\" nil))\n (color (signal \"#8b5cf6\"))\n (rects (signal (list))))\n ;; Draw effect — re-runs when rects changes\n (effect (fn ()\n (let ((el (get canvas-ref \"current\")))\n (when el\n (let ((ctx (host-call el \"getContext\" \"2d\")))\n (host-call ctx \"clearRect\" 0 0 280 160)\n (for-each (fn (r)\n (host-set! ctx \"fillStyle\" (get r \"c\"))\n (host-call ctx \"fillRect\" (get r \"x\") (get r \"y\") 20 20))\n (deref rects)))))))\n (canvas :ref canvas-ref :on-click (fn (e) ...)\n :width \"280\" :height \"160\")))" "lisp")) + (p "Every canvas operation is a raw " (code "host-call") " or " (code "host-set!") ". The foreign FFI layer wraps these into named SX functions: " (code "(fill-rect ctx 0 0 280 160)") " instead of " (code "(host-call ctx \"fillRect\" 0 0 280 160)") ".")) + (~docs/section :title "API" :id "foreign-api" (~docs/code :src (highlight @@ -181,6 +193,12 @@ "Modal state management where machine state IS a signal. " "Composes naturally with computed and effects.") + (~docs/section :title "Live Demo" :id "demo" + (p "A traffic light built from a signal and a transitions dict. Auto-advance uses an effect with " (code "set-timeout") " — when state changes, the effect re-runs and schedules the next transition.") + (~reactive-runtime/demo-machine) + (~docs/code :src (highlight "(defisland ~demo-machine ()\n (let ((state (signal \"red\"))\n (transitions (dict \"red\" \"green\" \"green\" \"yellow\" \"yellow\" \"red\"))\n (auto (signal false)))\n ;; Auto-advance: effect depends on both state and auto\n (effect (fn ()\n (when (deref auto)\n (let ((delay (if (= (deref state) \"yellow\") 1000 2500)))\n (let ((id (set-timeout\n (fn () (reset! state (get transitions (deref state))))\n delay)))\n (fn () (clear-timeout id)))))))\n ;; Display: reactive class on each light\n (div :class (str \"...\" (if (= (deref state) \"red\")\n \"bg-red-500\" \"bg-red-900/30\")) ...)))" "lisp")) + (p "The machine state is a signal — " (code "(deref state)") " in the effect body subscribes, so when the timeout fires and resets state, the effect re-runs with the new delay. The " (code "make-machine") " function wraps this pattern with transition tables, guards, and enter/exit hooks.")) + (~docs/section :title "API" :id "machine-api" (~docs/code :src (highlight @@ -211,6 +229,12 @@ "Command pattern built into signal stores. Commands are s-expressions " "in a history stack — trivially serializable, with transaction grouping for drag operations.") + (~docs/section :title "Live Demo" :id "demo" + (p "A counter with full undo/redo. Each operation pushes the previous state onto the undo stack. Undo pops from undo and pushes to redo. All state is reactive — buttons disable when their stack is empty.") + (~reactive-runtime/demo-commands) + (~docs/code :src (highlight "(defisland ~demo-commands ()\n (let ((value (signal 0))\n (undo-stack (signal (list)))\n (redo-stack (signal (list)))\n (do-cmd (fn (new-val)\n (batch (fn ()\n (swap! undo-stack (fn (s) (cons (deref value) s)))\n (reset! redo-stack (list))\n (reset! value new-val)))))\n (undo (fn ()\n (when (> (len (deref undo-stack)) 0)\n (batch (fn ()\n (let ((prev (first (deref undo-stack))))\n (swap! redo-stack (fn (r) (cons (deref value) r)))\n (reset! value prev)\n (swap! undo-stack rest)))))))\n (redo (fn () ...)))\n (span (deref value))\n (button :on-click (fn (e) (do-cmd (+ (deref value) 1))) \"+1\")\n (button :on-click (fn (e) (undo)) \"Undo\")))" "lisp")) + (p "Three signals and two functions. " (code "batch") " groups the three writes (push old, clear redo, set new) into one notification pass. The stacks use " (code "cons") "/" (code "first") "/" (code "rest") " — standard list operations. " (code "make-command-store") " wraps this pattern and adds transaction grouping.")) + (~docs/section :title "API" :id "commands-api" (~docs/code :src (highlight @@ -245,6 +269,12 @@ (code "requestAnimationFrame") " integration for canvas and animation apps, " "with automatic island lifecycle cleanup.") + (~docs/section :title "Live Demo" :id "demo" + (p "A ball bouncing across a track. Position is a signal — the " (code ":style") " attribute reads it reactively. The animation runs via " (code "set-interval") " at ~60fps; the effect's cleanup stops it when paused.") + (~reactive-runtime/demo-loop) + (~docs/code :src (highlight "(defisland ~demo-loop ()\n (let ((running (signal true))\n (x (signal 0))\n (dir (signal 1))\n (frames (signal 0)))\n ;; Animation effect — interval simulates rAF\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval\n (fn ()\n (batch (fn ()\n (swap! frames inc)\n (when (>= (deref x) 230) (reset! dir -1))\n (when (<= (deref x) 0) (reset! dir 1))\n (swap! x (fn (v) (+ v (* (deref dir) 3)))))))\n 16)))\n (fn () (clear-interval id))))))\n ;; Ball position driven by reactive style\n (div :class \"relative h-12 ...\" :style (str \"...\")\n (div :style (str \"left:\" (deref x) \"px\") ...))))" "lisp")) + (p "The " (code "running") " signal is read in the effect body — toggling it triggers cleanup (stops the interval) and re-evaluation (starts a new one or does nothing). The real " (code "make-loop") " uses " (code "request-animation-frame") " with the running-ref pattern instead.")) + (~docs/section :title "API" :id "loop-api" (~docs/code :src (highlight @@ -276,6 +306,12 @@ "Enhances the existing reactive-list with explicit key extraction callbacks " "for stable identity tracking across updates.") + (~docs/section :title "Live Demo" :id "demo" + (p "Items with stable " (code ":key") " identities. Reverse and rotate reorder the signal list — the reconciler moves existing DOM nodes instead of recreating them. Add and remove demonstrate insertion and deletion.") + (~reactive-runtime/demo-keyed-lists) + (~docs/code :src (highlight "(defisland ~demo-keyed-lists ()\n (let ((items (signal (list\n (dict \"id\" 1 \"text\" \"Alpha\" \"color\" \"violet\")\n (dict \"id\" 2 \"text\" \"Beta\" \"color\" \"blue\") ...)))\n (next-id (signal 6)))\n (button :on-click (fn (e) (swap! items reverse)) \"Reverse\")\n (button :on-click (fn (e)\n (swap! items (fn (old) (append (rest old) (first old)))))\n \"Rotate\")\n (ul (map (fn (item)\n (li :key (str (get item \"id\"))\n :class (str \"bg-\" (get item \"color\") \"-100 ...\")\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items)))))" "lisp")) + (p (code ":key") " on each " (code "li") " gives the reconciler stable identity. When " (code "reverse") " flips the list, the existing DOM nodes are moved — not destroyed and recreated. This preserves focus, scroll position, CSS transitions, and internal island state.")) + (~docs/section :title "API" :id "keyed-api" (~docs/code :src (highlight @@ -309,6 +345,12 @@ "Skip SSR entirely for canvas-heavy apps. Server returns a minimal HTML shell; " "all rendering happens client-side.") + (~docs/section :title "Live Demo" :id "demo" + (p "A mini single-page app rendered entirely client-side. Navigation switches views via a " (code "route") " signal — no server round-trips. Counter state persists across view switches because signals live in closures, not the DOM.") + (~reactive-runtime/demo-app-shell) + (~docs/code :src (highlight "(defisland ~demo-app-shell ()\n (let ((route (signal \"home\"))\n (count (signal 0)))\n ;; Client-side nav\n (nav\n (button :on-click (fn (e) (reset! route \"home\"))\n :class (str ... (if (= (deref route) \"home\") \"active\" \"inactive\"))\n \"Home\")\n ...)\n ;; Reactive view switching\n (cond\n (= (deref route) \"home\")\n (div (p \"Client-rendered. No server round-trip.\"))\n (= (deref route) \"counter\")\n (div (span (deref count)) ...)\n (= (deref route) \"about\")\n (div (p \"Entirely client-rendered.\")))))" "lisp")) + (p "This island IS a mini app shell. The " (code "make-app") " function generalizes the pattern: server returns " (code "
") " + SX loader, " (code "app-boot") " mounts the entry component, " (code "app-navigate!") " switches routes — all using existing " (code "sx-mount") " and signals.")) + (~docs/section :title "API" :id "app-api" (~docs/code :src (highlight @@ -325,3 +367,336 @@ "Uses existing " (code "sx-mount") " and " (code "sx-hydrate-islands") " from boot.sx.") (p "~330 lines. Orchestrates all other layers.")))) + + +;; =========================================================================== +;; Live demo islands +;; =========================================================================== + +;; --------------------------------------------------------------------------- +;; L0: Ref — timer ID in closure, DOM handle in dict +;; --------------------------------------------------------------------------- + +(defisland ~reactive-runtime/demo-ref () + (let ((input-ref (dict "current" nil)) + (ticks (signal 0)) + (running (signal false)) + (last-value (signal ""))) + (let ((_eff (effect (fn () + (when (deref running) + (let ((id (set-interval (fn () (swap! ticks inc)) 500))) + (fn () (clear-interval id)))))))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (p :class "text-xs font-semibold text-stone-500 mb-2" + "Timer — interval ID captured in effect closure") + (div :class "flex items-center gap-3 mb-4" + (span :class "text-2xl font-bold text-violet-900 font-mono w-16 text-center" + (deref ticks)) + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (swap! running not)) + (if (deref running) "Stop" "Start")) + (button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400" + :on-click (fn (e) (do (reset! running false) (reset! ticks 0))) + "Reset")) + (p :class "text-xs font-semibold text-stone-500 mb-2" + "DOM handle — element ref in dict") + (div :class "flex items-center gap-3" + (input :ref input-ref :type "text" :placeholder "Type something…" + :class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48") + (button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400" + :on-click (fn (e) + (reset! last-value (dom-get-prop (get input-ref "current") "value"))) + "Read") + (when (not (= (deref last-value) "")) + (span :class "text-sm text-stone-600 font-mono" + "\"" (deref last-value) "\""))))))) + + +;; --------------------------------------------------------------------------- +;; L1: Foreign — canvas drawing via host-call +;; --------------------------------------------------------------------------- + +(defisland ~reactive-runtime/demo-foreign () + (let ((canvas-ref (dict "current" nil)) + (color (signal "#8b5cf6")) + (count (signal 0))) + (let ((_eff (effect (fn () + (let ((el (get canvas-ref "current")) + (n (deref count)) + (c (deref color))) + (when el + (let ((ctx (host-call el "getContext" "2d"))) + (host-call ctx "clearRect" 0 0 280 160) + (host-set! ctx "fillStyle" c) + (host-call ctx "fillRect" 10 10 (min (* n 25) 260) (min (* n 18) 140))))))))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (canvas :ref canvas-ref :width "280" :height "160" + :class "border border-stone-300 rounded bg-white block mb-3") + (div :class "flex items-center gap-3" + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (swap! count inc)) + "Add") + (select :bind color + :class "px-2 py-1 rounded border border-stone-300 text-sm" + (option :value "#8b5cf6" "Violet") + (option :value "#3b82f6" "Blue") + (option :value "#ef4444" "Red") + (option :value "#22c55e" "Green")) + (button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400" + :on-click (fn (e) (reset! count 0)) + "Clear") + (span :class "text-sm text-stone-500" + (deref count) " squares")))))) + + +;; --------------------------------------------------------------------------- +;; L2: State Machine — traffic light +;; --------------------------------------------------------------------------- + +(defisland ~reactive-runtime/demo-machine () + (let ((state (signal "red")) + (transitions (dict "red" "green" "green" "yellow" "yellow" "red")) + (auto (signal false))) + (let ((_eff (effect (fn () + (when (deref auto) + (let ((delay (if (= (deref state) "yellow") 1000 2500))) + (let ((id (set-timeout + (fn () (reset! state (get transitions (deref state)))) + delay))) + (fn () (clear-timeout id))))))))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (div :class "flex items-center gap-6" + ;; Traffic light + (div :class "flex flex-col gap-2 p-3 bg-stone-800 rounded-lg" + (div :class (str "w-10 h-10 rounded-full transition-colors " + (if (= (deref state) "red") "bg-red-500 shadow-lg shadow-red-500/50" "bg-red-900/30"))) + (div :class (str "w-10 h-10 rounded-full transition-colors " + (if (= (deref state) "yellow") "bg-yellow-400 shadow-lg shadow-yellow-400/50" "bg-yellow-900/30"))) + (div :class (str "w-10 h-10 rounded-full transition-colors " + (if (= (deref state) "green") "bg-green-500 shadow-lg shadow-green-500/50" "bg-green-900/30")))) + ;; Controls + (div :class "space-y-3" + (div :class "flex items-center gap-2" + (span :class "text-sm font-medium text-stone-700" "State:") + (span :class "text-sm font-mono text-violet-700" (deref state))) + (div :class "flex items-center gap-2" + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) + (reset! state (get transitions (deref state)))) + "Next") + (button :class (str "px-3 py-1 rounded text-sm font-medium " + (if (deref auto) + "bg-amber-500 text-white hover:bg-amber-600" + "bg-stone-300 text-stone-700 hover:bg-stone-400")) + :on-click (fn (e) (swap! auto not)) + (if (deref auto) "Auto: ON" "Auto: OFF"))))))))) + + +;; --------------------------------------------------------------------------- +;; L3: Commands — counter with undo/redo +;; --------------------------------------------------------------------------- + +(defisland ~reactive-runtime/demo-commands () + (let ((value (signal 0)) + (history (signal (list))) + (future (signal (list))) + (undo-class (signal "bg-stone-200 text-stone-400 cursor-not-allowed")) + (redo-class (signal "bg-stone-200 text-stone-400 cursor-not-allowed"))) + (let ((do-cmd (fn (new-val) + (let ((old-val (deref value)) + (old-hist (deref history))) + (do + (reset! history (cons old-val old-hist)) + (reset! future (list)) + (reset! value new-val))))) + (undo (fn () + (let ((h (deref history))) + (when (> (len h) 0) + (let ((prev (first h)) + (old-val (deref value)) + (f (deref future))) + (do + (reset! future (cons old-val f)) + (reset! history (rest h)) + (reset! value prev))))))) + (redo (fn () + (let ((f (deref future))) + (when (> (len f) 0) + (let ((next-val (first f)) + (old-val (deref value)) + (h (deref history))) + (do + (reset! history (cons old-val h)) + (reset! future (rest f)) + (reset! value next-val))))))) + (_e1 (effect (fn () + (reset! undo-class + (if (> (len (deref history)) 0) + "bg-stone-600 text-white hover:bg-stone-700" + "bg-stone-200 text-stone-400 cursor-not-allowed"))))) + (_e2 (effect (fn () + (reset! redo-class + (if (> (len (deref future)) 0) + "bg-stone-600 text-white hover:bg-stone-700" + "bg-stone-200 text-stone-400 cursor-not-allowed")))))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (div :class "flex items-center gap-3 mb-3" + (span :class "text-3xl font-bold text-violet-900 font-mono w-20 text-center" + (deref value)) + (div :class "flex gap-1" + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (do-cmd (+ (deref value) 1))) + "+1") + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (do-cmd (+ (deref value) 5))) + "+5") + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (do-cmd (* (deref value) 2))) + "×2") + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (do-cmd 0)) + "0"))) + (div :class "flex items-center gap-3" + (button :class (str "px-3 py-1 rounded text-sm font-medium " (deref undo-class)) + :on-click (fn (e) (undo)) + "Undo") + (button :class (str "px-3 py-1 rounded text-sm font-medium " (deref redo-class)) + :on-click (fn (e) (redo)) + "Redo") + (span :class "text-xs text-stone-400 font-mono" + "history: " (deref (computed (fn () (len (deref history))))))))))) + + +;; --------------------------------------------------------------------------- +;; L4: Render Loop — bouncing ball +;; --------------------------------------------------------------------------- + +(defisland ~reactive-runtime/demo-loop () + (let ((running (signal true)) + (x (signal 0)) + (dir (signal 1)) + (frames (signal 0))) + (let ((_eff (effect (fn () + (when (deref running) + (let ((id (set-interval + (fn () + (batch (fn () + (swap! frames inc) + (when (>= (deref x) 230) (reset! dir -1)) + (when (<= (deref x) 0) (reset! dir 1)) + (swap! x (fn (v) (+ v (* (deref dir) 3))))))) + 16))) + (fn () (clear-interval id)))))))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (div :class "relative h-12 bg-white rounded border border-stone-200 mb-3 overflow-hidden" + (div :class "absolute top-1 w-10 h-10 rounded-full bg-violet-500 transition-none" + :style (str "left:" (deref x) "px"))) + (div :class "flex items-center gap-3" + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (swap! running not)) + (if (deref running) "Pause" "Play")) + (button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400" + :on-click (fn (e) + (batch (fn () + (reset! running false) + (reset! x 0) + (reset! dir 1) + (reset! frames 0)))) + "Reset") + (span :class "text-sm text-stone-500 font-mono" + "frames: " (deref frames))))))) + + +;; --------------------------------------------------------------------------- +;; L5: Keyed Lists — reorderable items +;; --------------------------------------------------------------------------- + +(defisland ~reactive-runtime/demo-keyed-lists () + (let ((next-id (signal 1)) + (items (signal (list))) + (colors (list "violet" "blue" "green" "amber" "red" "stone")) + (add-item (fn (e) + (let ((id (deref next-id)) + (old (deref items))) + (do + (reset! items (append old (dict "id" id + "text" (str "Item " id) + "color" (nth colors (mod (- id 1) 6))))) + (reset! next-id (+ id 1)))))) + (remove-item (fn (id) + (reset! items (filter (fn (item) (not (= (get item "id") id))) (deref items)))))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (div :class "flex items-center gap-2 mb-3" + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click add-item + "Add Item") + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (reset! items (reverse (deref items)))) + "Reverse") + (span :class "text-sm text-stone-500" + (deref (computed (fn () (len (deref items))))) " items")) + (ul :class "space-y-1" + (map (fn (item) + (li :key (str (get item "id")) + :class (str "flex items-center justify-between rounded px-3 py-2 text-sm bg-" (get item "color") "-100 text-" (get item "color") "-800") + (span (get item "text")) + (button :class "text-stone-400 hover:text-red-500 text-xs ml-2" + :on-click (fn (e) (remove-item (get item "id"))) + "✕"))) + (deref items)))))) + + +;; --------------------------------------------------------------------------- +;; L6: App Shell — client-side mini-app +;; --------------------------------------------------------------------------- + +(defisland ~reactive-runtime/demo-app-shell () + (let ((route (signal "home")) + (count (signal 0))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + ;; Client-side nav bar + (nav :class "flex gap-1 border-b border-stone-200 pb-2 mb-3" + (button :class (str "px-2 py-1 text-xs rounded " + (if (= (deref route) "home") + "bg-violet-600 text-white font-medium" + "bg-stone-200 text-stone-600 hover:bg-stone-300")) + :on-click (fn (e) (reset! route "home")) + "Home") + (button :class (str "px-2 py-1 text-xs rounded " + (if (= (deref route) "counter") + "bg-violet-600 text-white font-medium" + "bg-stone-200 text-stone-600 hover:bg-stone-300")) + :on-click (fn (e) (reset! route "counter")) + "Counter") + (button :class (str "px-2 py-1 text-xs rounded " + (if (= (deref route) "about") + "bg-violet-600 text-white font-medium" + "bg-stone-200 text-stone-600 hover:bg-stone-300")) + :on-click (fn (e) (reset! route "about")) + "About")) + ;; Reactive view switching + (div :class "min-h-24" + (if (= (deref route) "home") + (div :class "p-3" + (h3 :class "font-bold text-stone-800 mb-1" "Home") + (p :class "text-sm text-stone-600" + "Client-side rendered. No server round-trip for navigation.")) + (if (= (deref route) "counter") + (div :class "p-3" + (h3 :class "font-bold text-stone-800 mb-2" "Counter") + (div :class "flex items-center gap-3" + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (swap! count dec)) + "−") + (span :class "text-2xl font-bold text-violet-900 font-mono w-12 text-center" + (deref count)) + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click (fn (e) (swap! count inc)) + "+")) + (p :class "text-xs text-stone-400 mt-2" + "State persists across view switches — signals live in closures.")) + (div :class "p-3" + (h3 :class "font-bold text-stone-800 mb-1" "About") + (p :class "text-sm text-stone-600" + "This mini-app is entirely client-rendered. The server provides only " + "the component definition and mount point — zero HTML content."))))))))