SX parser doesn't process \u escapes — they render as literal text. Use actual UTF-8 characters (→, —, £, ⬡) directly in source. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
187 lines
14 KiB
Plaintext
187 lines
14 KiB
Plaintext
;; ---------------------------------------------------------------------------
|
|
;; Spreads — child-to-parent communication across render boundaries
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(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. "
|
|
"This inverts the normal direction of data flow: children tell parents how to look.")
|
|
|
|
;; =====================================================================
|
|
;; I. The primitives
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "Three primitives" :id "primitives"
|
|
(p "The spread system has three orthogonal primitives. Each operates at a "
|
|
"different level of the render pipeline.")
|
|
|
|
(~docs/subsection :title "1. make-spread / spread? / spread-attrs"
|
|
(p "A spread is a value type. " (code "make-spread") " creates one from a dict of "
|
|
"attributes. When the renderer encounters a spread as a child of an element, "
|
|
"it merges the attrs onto the parent element instead of appending a DOM node.")
|
|
(~docs/code :code (highlight "(defcomp ~highlight (&key colour)\n (make-spread {\"class\" (str \"highlight-\" colour)\n \"data-highlight\" colour}))" "lisp"))
|
|
(p "Use it as a child of any element:")
|
|
(~docs/code :code (highlight "(div (~highlight :colour \"yellow\")\n \"This div gets class=highlight-yellow\")" "lisp"))
|
|
(p (code "class") " values are appended (space-joined). "
|
|
(code "style") " values are appended (semicolon-joined). "
|
|
"All other attributes overwrite."))
|
|
|
|
(~docs/subsection :title "2. collect! / collected / clear-collected!"
|
|
(p "Render-time accumulators. Values are collected into named buckets "
|
|
"during rendering and retrieved at flush points. Deduplication is automatic.")
|
|
(~docs/code :code (highlight ";; Deep inside a component tree:\n(collect! \"cssx\" \".sx-bg-red-500{background-color:hsl(0,72%,53%)}\")\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 "This is upward communication through the render tree: "
|
|
"a deeply nested component contributes a CSS rule, and the layout "
|
|
"emits all accumulated rules as a single " (code "<style>") " tag. "
|
|
"No prop threading, no context providers, no global state."))
|
|
|
|
(~docs/subsection :title "3. 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.")
|
|
(~docs/code :code (highlight "(defisland ~themed-card ()\n (let ((theme (signal \"violet\")))\n (div (~cssx/tw :tokens (str \"bg-\" (deref theme) \"-500 p-4\"))\n (button :on-click (fn (e) (reset! theme \"rose\"))\n \"change theme\"))))" "lisp"))
|
|
(p "When " (code "theme") " changes from " (code "\"violet\"") " to "
|
|
(code "\"rose\"") ":")
|
|
(ul :class "list-disc pl-5 space-y-1 text-stone-600"
|
|
(li "Old classes (" (code "sx-bg-violet-500") ") are removed from the element")
|
|
(li "New classes (" (code "sx-bg-rose-500") ") 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."))))
|
|
|
|
;; =====================================================================
|
|
;; II. Orthogonality
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "Orthogonality across boundaries" :id "orthogonality"
|
|
(p "The three primitives 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)")))))
|
|
|
|
;; =====================================================================
|
|
;; III. CSSX as use case
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "CSSX: the first application" :id "cssx"
|
|
(p (code "~cssx/tw") " is a component that uses all three primitives:")
|
|
(~docs/code :code (highlight "(defcomp ~cssx/tw (tokens)\n (let ((token-list (filter (fn (t) (not (= t \"\")))\n (split (or tokens \"\") \" \")))\n (results (map cssx-process-token token-list))\n (valid (filter (fn (r) (not (nil? r))) results))\n (classes (map (fn (r) (get r \"cls\")) valid))\n (rules (map (fn (r) (get r \"rule\")) valid))\n (_ (for-each (fn (rule) (collect! \"cssx\" rule)) rules)))\n (if (empty? classes)\n nil\n (make-spread {\"class\" (join \" \" classes)\n \"data-tw\" (or tokens \"\")}))))" "lisp"))
|
|
(p "It's a regular " (code "defcomp") ". It uses " (code "make-spread") " to "
|
|
"inject classes onto its parent, " (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 primitives enable:")
|
|
(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")))
|
|
|
|
;; =====================================================================
|
|
;; IV. 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:")
|
|
(~docs/code :code (highlight ";; Define once\n(define heading-style (~cssx/tw :tokens \"text-violet-700 text-2xl font-bold\"))\n(define nav-link (~cssx/tw :tokens \"text-stone-500 text-sm\"))\n(define card-base (~cssx/tw :tokens \"bg-stone-50 rounded-lg p-4\"))\n\n;; Use everywhere\n(div card-base\n (h1 heading-style \"Title\")\n (a nav-link :href \"/\" \"Home\"))" "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 "define") ".")
|
|
(p "Namespacing prevents clashes — " (code "~app/heading") " vs "
|
|
(code "~admin/heading") " are different components in different namespaces."))
|
|
|
|
;; =====================================================================
|
|
;; V. 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 three orthogonal primitives that already existed for other reasons:")
|
|
(ul :class "list-disc pl-5 space-y-1 text-stone-600"
|
|
(li (strong "spread") " — child-to-parent attr injection (existed for component composition)")
|
|
(li (strong "collect!") " — render-time accumulation (existed for CSS rule batching)")
|
|
(li (strong "reactive-spread") " — just the obvious combination of spread + effect"))
|
|
(p "No new concepts. No new runtime. No new API surface."))
|
|
|
|
;; =====================================================================
|
|
;; VI. The deeper primitive
|
|
;; =====================================================================
|
|
|
|
(~docs/section :title "The deeper primitive: provide / context / emit!" :id "provide"
|
|
(p "Spread and collect are both instances of the same pattern: "
|
|
(strong "child communicates upward through the render tree") ". "
|
|
"The general form is " (code "provide") "/" (code "context") "/" (code "emit!")
|
|
" — render-time dynamic scope.")
|
|
|
|
(~docs/subsection :title "The unification"
|
|
(~docs/table
|
|
:headers (list "Current" "General form" "Direction")
|
|
:rows (list
|
|
(list "collect! / collected" "emit! / emitted" "upward (child → scope)")
|
|
(list "make-spread" "emit! into implicit parent-attrs provider" "upward (child → parent)")
|
|
(list "(nothing yet)" "context" "downward (scope → child)")))
|
|
(p (code "provide") " creates a named scope with a value (downward) and an accumulator (upward). "
|
|
(code "context") " reads the value. " (code "emit!") " appends to the accumulator. "
|
|
(code "emitted") " retrieves accumulated values.")
|
|
(~docs/code :code (highlight ";; Downward: theme context\n(provide \"theme\" {:primary \"violet\" :font \"serif\"}\n (h1 :style (str \"color:\" (get (context \"theme\") :primary))\n \"Themed heading\"))\n\n;; Upward: script accumulation (like collect!)\n(provide \"scripts\" nil\n (div\n (emit! \"scripts\" \"analytics.js\")\n (div (emit! \"scripts\" \"charts.js\") \"chart\"))\n (for-each (fn (s) (script :src s)) (emitted \"scripts\")))\n\n;; Both at once\n(provide \"page\" {:title \"Home\"}\n (h1 (context \"page\" :title))\n (emit! \"page\" {:meta \"og:title\" :content \"Home\"})\n (for-each (fn (m) (meta :name (get m :meta) :content (get m :content)))\n (emitted \"page\")))" "lisp")))
|
|
|
|
(~docs/subsection :title "What this means"
|
|
(p "Three mechanisms collapse into one:")
|
|
(ul :class "list-disc pl-5 space-y-1 text-stone-600"
|
|
(li (code "collect!") " = " (code "emit!") " with deduplication")
|
|
(li (code "spread") " = " (code "emit!") " into implicit parent-attrs provider")
|
|
(li (code "collected") " = " (code "emitted"))
|
|
(li (code "context") " = downward data flow (new capability — no current equivalent)"))
|
|
(p "The reactive-spread we just built would naturally follow — the effect tracks "
|
|
"signal deps in the " (code "emit!") " call, the provider accumulates, the element applies.")
|
|
(p :class "text-stone-500 italic"
|
|
"This is the planned next step. The current primitives (spread, collect, reactive-spread) "
|
|
"work and are fully orthogonal. " (code "provide/context/emit!") " will be the deeper "
|
|
"foundation they are reimplemented on top of."))
|
|
|
|
(~docs/note
|
|
(p (strong "Plan: ") (code "provide") "/" (code "context") "/" (code "emit!") " is specced "
|
|
"and ready to implement. Per-name stacks. Each entry has a value and an emitted list. "
|
|
"Four primitives: " (code "provide") " (special form), " (code "context") ", "
|
|
(code "emit!") ", " (code "emitted") ". Platform provides " (code "provide-push!")
|
|
"/" (code "provide-pop!") ". See the implementation plan for details.")))))
|