Files
rose-ash/sx/sx/spreads.sx
giles 11fdd1a840
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m54s
Unify scoped effects: scope as general primitive, provide as sugar
- 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>
2026-03-13 17:30:34 +00:00

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")
".")))))