All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m54s
- Add `scope` special form to eval.sx: (scope name body...) or (scope name :value v body...) — general dynamic scope primitive - `provide` becomes sugar: (provide name value body...) calls scope - Rename provide-push!/provide-pop! to scope-push!/scope-pop! throughout all adapters (async, dom, html, sx) and platform implementations - Update boundary.sx: Tier 5 now "Scoped effects" with scope-push!/ scope-pop! as primary, provide-push!/provide-pop! as aliases - Add scope form handling to async adapter and aser wire format - Update sx-browser.js, sx_ref.py (bootstrapped output) - Add scopes.sx docs page, update provide/spreads/demo docs - Update nav-data, page-functions, docs page definitions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
290 lines
18 KiB
Plaintext
290 lines
18 KiB
Plaintext
;; ---------------------------------------------------------------------------
|
|
;; Spreads — child-to-parent communication across render boundaries
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
|
|
;; ---- Layout helper ----
|
|
|
|
(defcomp ~geography/demo-example (&key demo code)
|
|
(div :class "grid grid-cols-1 lg:grid-cols-2 gap-4 my-6 items-start"
|
|
(div :class "border border-dashed border-stone-300 rounded-lg p-4 bg-stone-50 min-h-[80px]"
|
|
demo)
|
|
(div :class "not-prose bg-stone-100 rounded-lg p-4 overflow-x-auto"
|
|
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code)))))
|
|
|
|
|
|
;; ---- Demo components ----
|
|
|
|
(defcomp ~geography/demo-callout (&key type)
|
|
(make-spread
|
|
(cond
|
|
(= type "info") {"style" "border-left:4px solid #3b82f6;padding:0.5rem 0.75rem;background:#eff6ff;border-radius:0.25rem"
|
|
"data-callout" "info"}
|
|
(= type "warning") {"style" "border-left:4px solid #f59e0b;padding:0.5rem 0.75rem;background:#fffbeb;border-radius:0.25rem"
|
|
"data-callout" "warning"}
|
|
(= type "success") {"style" "border-left:4px solid #10b981;padding:0.5rem 0.75rem;background:#ecfdf5;border-radius:0.25rem"
|
|
"data-callout" "success"}
|
|
:else {"style" "border-left:4px solid #78716c;padding:0.5rem 0.75rem;background:#fafaf9;border-radius:0.25rem"
|
|
"data-callout" "default"})))
|
|
|
|
(defcomp ~geography/demo-spread-basic ()
|
|
(div :class "space-y-3"
|
|
(div (~geography/demo-callout :type "info")
|
|
(p :class "text-sm" "Info — styled by its child."))
|
|
(div (~geography/demo-callout :type "warning")
|
|
(p :class "text-sm" "Warning — same component, different type."))
|
|
(div (~geography/demo-callout :type "success")
|
|
(p :class "text-sm" "Success — child tells parent how to look."))))
|
|
|
|
(defcomp ~geography/demo-cssx-tw ()
|
|
(div :class "space-y-3"
|
|
(div (~cssx/tw :tokens "bg-violet-100 rounded-lg p-4")
|
|
(h4 (~cssx/tw :tokens "text-violet-800 font-semibold text-lg") "Styled via ~cssx/tw")
|
|
(p (~cssx/tw :tokens "text-stone-600 text-sm mt-1")
|
|
"Classes injected from spread child."))
|
|
(div (~cssx/tw :tokens "bg-rose-50 rounded-lg p-4 border border-rose-200")
|
|
(h4 (~cssx/tw :tokens "text-rose-700 font-semibold text-lg") "Different tokens")
|
|
(p (~cssx/tw :tokens "text-stone-600 text-sm mt-1")
|
|
"CSS rules JIT-generated. No build step."))))
|
|
|
|
(defcomp ~geography/demo-semantic-vars ()
|
|
(let ((card (~cssx/tw :tokens "bg-stone-50 rounded-lg p-4 shadow-sm border border-stone-200"))
|
|
(heading (~cssx/tw :tokens "text-violet-700 text-lg font-bold"))
|
|
(body-text (~cssx/tw :tokens "text-stone-600 text-sm mt-1")))
|
|
(div :class "space-y-3"
|
|
(div card
|
|
(h4 heading "First Card")
|
|
(p body-text "Named spreads bound with let."))
|
|
(div card
|
|
(h4 heading "Second Card")
|
|
(p body-text "Same variables, consistent look.")))))
|
|
|
|
(defisland ~geography/demo-reactive-spread ()
|
|
(let ((colour (signal "violet")))
|
|
(div (~cssx/tw :tokens (str "rounded-lg p-4 transition-all duration-300 bg-" (deref colour) "-100 border border-" (deref colour) "-300"))
|
|
(h4 (~cssx/tw :tokens (str "text-" (deref colour) "-800 font-semibold text-lg"))
|
|
(str "Theme: " (deref colour)))
|
|
(p (~cssx/tw :tokens "text-stone-600 text-sm mt-2 mb-3")
|
|
"Click to change theme. Reactive spreads surgically update classes.")
|
|
(div :class "flex gap-2 flex-wrap"
|
|
(button :on-click (fn (e) (reset! colour "violet"))
|
|
(~cssx/tw :tokens "bg-violet-500 text-white px-3 py-1.5 rounded text-sm font-medium")
|
|
"Violet")
|
|
(button :on-click (fn (e) (reset! colour "rose"))
|
|
(~cssx/tw :tokens "bg-rose-500 text-white px-3 py-1.5 rounded text-sm font-medium")
|
|
"Rose")
|
|
(button :on-click (fn (e) (reset! colour "amber"))
|
|
(~cssx/tw :tokens "bg-amber-500 text-white px-3 py-1.5 rounded text-sm font-medium")
|
|
"Amber")
|
|
(button :on-click (fn (e) (reset! colour "emerald"))
|
|
(~cssx/tw :tokens "bg-emerald-500 text-white px-3 py-1.5 rounded text-sm font-medium")
|
|
"Emerald")))))
|
|
|
|
|
|
;; ---- Page content ----
|
|
|
|
(defcomp ~geography/spreads-content ()
|
|
(~docs/page :title "Spreads"
|
|
|
|
(p :class "text-stone-500 text-sm italic mb-8"
|
|
"A spread is a value that, when returned as a child of an element, "
|
|
"injects attributes onto its parent instead of rendering as content. "
|
|
"Internally, spreads work through "
|
|
(a :href "/sx/(geography.(scopes))" :class "text-violet-600 hover:underline" "scopes")
|
|
" — every element creates a scope, and spread children emit into it.")
|
|
|
|
;; =====================================================================
|
|
;; I. How it works
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "How it works" :id "mechanism"
|
|
(p "Every element wraps its children in a " (code "provide") " scope named "
|
|
(code "\"element-attrs\"") ". When the renderer encounters a spread child, "
|
|
"it calls " (code "emit!") " to push the spread's attrs into that scope. "
|
|
"After all children render, the element collects the emitted attrs and merges them. "
|
|
"This is the " (a :href "/sx/(geography.(provide))" :class "text-violet-600 hover:underline"
|
|
"provide/emit!") " mechanism — spreads are one instance of it.")
|
|
(p "The user-facing API is " (code "make-spread") " — it creates a spread value from a dict. "
|
|
(code "spread?") " tests for one. " (code "spread-attrs") " extracts the dict. "
|
|
"But the actual delivery from child to parent goes through provide/emit!, not through "
|
|
"return-value inspection.")
|
|
|
|
(~geography/demo-example
|
|
:demo (~geography/demo-spread-basic)
|
|
:code (highlight "(defcomp ~callout (&key type)\n (make-spread\n (cond\n (= type \"info\")\n {\"style\" \"border-left:4px solid\n #3b82f6; background:#eff6ff\"}\n (= type \"warning\")\n {\"style\" \"border-left:4px solid\n #f59e0b; background:#fffbeb\"}\n (= type \"success\")\n {\"style\" \"border-left:4px solid\n #10b981; background:#ecfdf5\"})))\n\n;; Child injects attrs onto parent:\n(div (~callout :type \"info\")\n \"This div gets the callout style.\")" "lisp"))
|
|
|
|
(p (code "class") " values are appended (space-joined). "
|
|
(code "style") " values are appended (semicolon-joined). "
|
|
"All other attributes overwrite.")
|
|
|
|
(p "Tolerant " (code "emit!") " means a spread outside any element context "
|
|
"(in a fragment, " (code "begin") ", or bare " (code "map") ") silently vanishes "
|
|
"instead of crashing — there's no provider to emit into, so it's a no-op."))
|
|
|
|
;; =====================================================================
|
|
;; II. collect! — the other upward channel
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "collect! — the other upward channel" :id "collect"
|
|
(p "Spreads use " (code "scope") "/" (code "emit!") " (scoped, no dedup). "
|
|
(code "collect!") "/" (code "collected") " is also backed by scopes — "
|
|
"a lazy root scope with automatic deduplication. Used for CSS rule accumulation.")
|
|
(~docs/code :code (highlight ";; Deep inside a component tree:\n(collect! \"cssx\" \".sx-bg-red-500{background:red}\")\n\n;; At the flush point (once, in the layout):\n(let ((rules (collected \"cssx\")))\n (clear-collected! \"cssx\")\n (raw! (str \"<style>\" (join \"\" rules) \"</style>\")))" "lisp"))
|
|
(p "Both are upward communication through the render tree, but with different "
|
|
"semantics — " (code "emit!") " is scoped to the nearest provider, "
|
|
(code "collect!") " is global and deduplicates."))
|
|
|
|
;; =====================================================================
|
|
;; III. Reactive spreads (islands)
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "Reactive spreads" :id "reactive"
|
|
(~docs/subsection :title "reactive-spread (islands)"
|
|
(p "Inside an island, when a spread's value depends on signals, "
|
|
(code "reactive-spread") " tracks signal dependencies and surgically "
|
|
"updates the parent element's attributes when signals change.")
|
|
|
|
(~geography/demo-example
|
|
:demo (~geography/demo-reactive-spread)
|
|
:code (highlight "(defisland ~themed-card ()\n (let ((theme (signal \"violet\")))\n (div (~cssx/tw :tokens\n (str \"bg-\" (deref theme)\n \"-100 p-4\"))\n (button\n :on-click\n (fn (e) (reset! theme \"rose\"))\n \"change theme\"))))" "lisp"))
|
|
|
|
(p "When " (code "theme") " changes:")
|
|
(ul :class "list-disc pl-5 space-y-1 text-stone-600"
|
|
(li "Old classes are removed from the element")
|
|
(li "New classes are added")
|
|
(li "New CSS rules are JIT-generated and flushed to the live stylesheet")
|
|
(li "No re-render. No VDOM. No diffing. Just attr surgery."))))
|
|
|
|
;; =====================================================================
|
|
;; IV. Orthogonality
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "Orthogonality across boundaries" :id "orthogonality"
|
|
(p "Spread (via provide/emit!), collect!, and reactive-spread operate at different "
|
|
"levels of the render pipeline. They compose without knowing about each other.")
|
|
|
|
(~docs/table
|
|
:headers (list "Primitive" "Direction" "Boundary" "When")
|
|
:rows (list
|
|
(list "spread" "child → parent" "element boundary" "render time")
|
|
(list "collect!" "child → ancestor" "render tree" "render time")
|
|
(list "reactive-spread" "child → parent" "element boundary" "signal change")))
|
|
|
|
(~docs/subsection :title "Server rendering (HTML adapter)"
|
|
(p "On the server, " (code "~cssx/tw") " returns a spread. The HTML renderer "
|
|
"merges the class onto the parent element. " (code "collect!") " accumulates "
|
|
"CSS rules. " (code "~cssx/flush") " emits a single " (code "<style>") " tag. "
|
|
"Result: static HTML with all classes and styles baked in."))
|
|
|
|
(~docs/subsection :title "Client hydration (DOM adapter)"
|
|
(p "On the client, the same " (code "~cssx/tw") " call inside an island "
|
|
"produces a spread that the DOM adapter merges onto the parent element. "
|
|
"If the spread depends on signals, " (code "reactive-spread") " wraps it "
|
|
"in an effect. Static spreads are applied once; reactive ones track deps."))
|
|
|
|
(~docs/subsection :title "Morph (navigation)"
|
|
(p "When the server sends new HTML during SX navigation, the morph algorithm "
|
|
"enters islands, finds lakes, updates server content. Reactive spreads are "
|
|
"protected: " (code "data-sx-reactive-attrs") " tells the morph to skip "
|
|
"attributes managed by signal effects. The server water flows through; "
|
|
"the reactive rocks stay put."))
|
|
|
|
(~docs/subsection :title "The matrix"
|
|
(~docs/table
|
|
:headers (list "" "Server state" "Client state (signals)")
|
|
:rows (list
|
|
(list "Server rendering" "spread + collect! (pure hypermedia)" "SSR + hydrated reactive-spread")
|
|
(list "Client rendering" "spread in wire format (aser)" "reactive-spread (live)")))))
|
|
|
|
;; =====================================================================
|
|
;; V. CSSX as use case
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "CSSX: the first application" :id "cssx"
|
|
(p (code "~cssx/tw") " is the primary consumer of spreads:")
|
|
|
|
(~geography/demo-example
|
|
:demo (~geography/demo-cssx-tw)
|
|
:code (highlight "(defcomp ~cssx/tw (tokens)\n (let ((token-list\n (filter (fn (t) (not (= t \"\")))\n (split (or tokens \"\") \" \")))\n (results\n (map cssx-process-token\n token-list))\n (classes (map (fn (r)\n (get r \"cls\")) results))\n (rules (map (fn (r)\n (get r \"rule\")) results)))\n (for-each (fn (rule)\n (collect! \"cssx\" rule)) rules)\n (make-spread\n {\"class\" (join \" \" classes)})))" "lisp"))
|
|
|
|
(p "It uses " (code "make-spread") " to inject classes, "
|
|
(code "collect!") " to accumulate CSS rules for batch flushing, and "
|
|
"when called inside an island with signal-dependent tokens, "
|
|
(code "reactive-spread") " makes it live.")
|
|
(p "But " (code "~cssx/tw") " is just one instance. The same mechanism enables:")
|
|
(ul :class "list-disc pl-5 space-y-1 text-stone-600"
|
|
(li (code "~aria") " — reactive accessibility attributes driven by UI state")
|
|
(li (code "~data-attrs") " — signal-driven data attributes for coordination")
|
|
(li (code "~conditional-attrs") " — presence/absence of attributes based on state")
|
|
(li "Any component that needs to inject attributes onto its parent")))
|
|
|
|
;; =====================================================================
|
|
;; VI. Semantic variables
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "Semantic style variables" :id "variables"
|
|
(p "Because " (code "~cssx/tw") " returns a spread, and spreads are values, "
|
|
"you can bind them to names:")
|
|
|
|
(~geography/demo-example
|
|
:demo (~geography/demo-semantic-vars)
|
|
:code (highlight "(let ((card (~cssx/tw :tokens\n \"bg-stone-50 rounded-lg p-4\n shadow-sm border\"))\n (heading (~cssx/tw :tokens\n \"text-violet-700 text-lg\n font-bold\"))\n (body (~cssx/tw :tokens\n \"text-stone-600 text-sm\")))\n ;; Reuse everywhere:\n (div card\n (h4 heading \"First Card\")\n (p body \"Same variables.\"))\n (div card\n (h4 heading \"Second Card\")\n (p body \"Consistent look.\")))" "lisp"))
|
|
|
|
(p "These are semantic names wrapping utility tokens. Change the definition, "
|
|
"every use updates. No build step, no CSS-in-JS runtime. Just " (code "let") ".")
|
|
(p "Namespacing prevents clashes — " (code "~app/heading") " vs "
|
|
(code "~admin/heading") " are different components in different namespaces."))
|
|
|
|
;; =====================================================================
|
|
;; VII. What nothing else does
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "What nothing else does" :id "unique"
|
|
(p "React can't do this. In React, attributes live in the parent's JSX. "
|
|
"A child component cannot inject attrs onto its parent element. You'd need "
|
|
"to lift state up, pass callbacks, use context, or reach for " (code "forwardRef")
|
|
" — all of which couple the child to the parent's rendering logic.")
|
|
(p "CSS-in-JS libraries (styled-components, Emotion) create " (em "wrapper elements")
|
|
". They don't inject attrs onto an existing element from a child position. "
|
|
"And they need a build step, a runtime, a theme provider.")
|
|
(p "SX does it with " (code "provide") "/" (code "emit!") " — render-time dynamic scope "
|
|
"that already existed for other reasons:")
|
|
(ul :class "list-disc pl-5 space-y-1 text-stone-600"
|
|
(li (strong "provide/emit!") " — scoped upward communication (the general mechanism)")
|
|
(li (strong "make-spread") " — creates spread values that emit into element-attrs providers")
|
|
(li (strong "reactive-spread") " — wraps emit! in a signal effect for live updates"))
|
|
(p "No new concepts. No new runtime. No new API surface."))
|
|
|
|
;; =====================================================================
|
|
;; VIII. The general primitive
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "The general primitive" :id "provide"
|
|
(p "Spreads, collect!, and context are all instances of one mechanism: "
|
|
(code "provide") "/" (code "emit!") " — render-time dynamic scope. "
|
|
(a :href "/sx/(geography.(provide))" :class "text-violet-600 hover:underline"
|
|
"See the full provide/context/emit! article")
|
|
" for the general primitive, nested scoping, and its other uses.")
|
|
|
|
(~docs/table
|
|
:headers (list "Mechanism" "General form" "Direction")
|
|
:rows (list
|
|
(list "spread" "emit! into element-attrs provider" "upward (child → parent)")
|
|
(list "collect! / collected" "emit! / emitted (global, deduped)" "upward (child → scope)")
|
|
(list "theme / config" "context" "downward (scope → child)")))
|
|
|
|
(~docs/note
|
|
(p (strong "Spec: ") "The provide/emit! primitives are declared in "
|
|
(a :href "/sx/(language.(spec.(explore.boundary)))" :class "font-mono text-violet-600 hover:underline text-sm" "boundary.sx")
|
|
" (Tier 5: Dynamic scope). The " (code "provide") " special form is in "
|
|
(a :href "/sx/(language.(spec.(explore.evaluator)))" :class "font-mono text-violet-600 hover:underline text-sm" "eval.sx")
|
|
". Element rendering with provide/emit! is visible in all four adapter specs: "
|
|
(a :href "/sx/(language.(spec.(explore.adapter-html)))" :class "font-mono text-violet-600 hover:underline text-sm" "adapter-html")
|
|
", "
|
|
(a :href "/sx/(language.(spec.(explore.adapter-async)))" :class "font-mono text-violet-600 hover:underline text-sm" "adapter-async")
|
|
", "
|
|
(a :href "/sx/(language.(spec.(explore.adapter-sx)))" :class "font-mono text-violet-600 hover:underline text-sm" "adapter-sx")
|
|
", "
|
|
(a :href "/sx/(language.(spec.(explore.adapter-dom)))" :class "font-mono text-violet-600 hover:underline text-sm" "adapter-dom")
|
|
".")))))
|