From cdbb5bb4ba397cd8058dc74e4d990ad3edda0665 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 30 Jun 2026 17:11:17 +0000 Subject: [PATCH] =?UTF-8?q?host:=20composition-objects=20render-fold=20?= =?UTF-8?q?=E2=80=94=20seq/par/alt/each=20+=20recursion=20+=20context=20(k?= =?UTF-8?q?eystone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cards-as-OBJECTS model (plans/composition-objects.md): an object's :body is a tiny UI language over content-addressed object refs; the render-fold is its interpreter. Four combinators — seq (sequence) / row,grid (layout/par) / alt+when (conditional/or) / each (iteration/loop) — plus field/text/card leaves, ref (transclude), and tmpl (recursion). The two fundamentals designed IN: (1) recursion via self-referential named templates (tmpl) + each over (children) + a depth guard — renders trees (verified: a nested type hierarchy -> [Types[Article][Card[Image][Callout]]]); (2) the context is an extensible ENVIRONMENT — reads it, extends it (:item, :depth) — so behaviour (Slice 9) and reactivity (signals) plug in via the context with no new combinators. and/or/choice fall out of one axis ( on forks) x the container strategy (render-all vs render-first), so Alt isn't a new node — it's 'first'. The unifying property, proven: the object's CID is its DEFINITION (query/template/every when-variant); render is the EXECUTION (which items/branch/context). One object renders two ways by context (anon -> 'Please log in', authed -> 'Members area'). Render-fold and the Slice-9 behaviour interpreter are the same shape — interpreters over content-addressed objects. lib/host/compose.sx is self-contained (no blog deps); verified via sx_eval (every combinator + a recursive tree + a full composed doc across two contexts). Roadmap: wire :body into host/blog-render, each-source=graph-query, live context, Lexical->card-objects import, block editor. Co-Authored-By: Claude Opus 4.8 --- lib/host/compose.sx | 98 ++++++++++++++++++++++++++++++++++++ plans/composition-objects.md | 78 ++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 lib/host/compose.sx create mode 100644 plans/composition-objects.md diff --git a/lib/host/compose.sx b/lib/host/compose.sx new file mode 100644 index 00000000..507dd72c --- /dev/null +++ b/lib/host/compose.sx @@ -0,0 +1,98 @@ +;; lib/host/compose.sx — the composition / object render-fold (plans/composition-objects.md). +;; +;; An object's :body is a composition node — a tiny UI language over object refs. The +;; render-fold below is its interpreter. Four combinators (seq/row/alt/each) + leaves +;; (field/text/card) + ref + recursion (tmpl). The context is an EXTENSIBLE ENVIRONMENT: +;; `when` reads it, `each` extends it (:item, :depth). Same predicate set as the type +;; guards. The object's CID is its DEFINITION; render is the EXECUTION (per context+data). +;; Self-contained (no blog deps) so the model can be proven in isolation. + +;; ── predicates for `when` (over the context environment) ──────────── +(define host/comp--pred? + (fn (pred ctx) + (let ((op (str (first pred)))) + (cond + ((= op "has") (not (nil? (get ctx (str (first (rest pred))))))) + ((= op "eq") (= (str (get ctx (str (first (rest pred))))) (str (first (rest (rest pred)))))) + ((= op "not") (not (host/comp--pred? (first (rest pred)) ctx))) + (else false))))) + +;; the value of a leaf (field): the current :item's key, else the context's key. +(define host/comp--field + (fn (k ctx) + (let ((item (get ctx "item")) (key (str k))) + (if (and item (not (nil? (get item key)))) + (str (get item key)) + (str (or (get ctx key) "")))))) + +;; the source collection for `each`: literal items, the :item's :children (trees), or a +;; named list field on the :item. (A graph-query source is wiring step 3, plan roadmap.) +(define host/comp--source + (fn (src ctx) + (let ((op (str (first src))) (item (get ctx "item"))) + (cond + ((= op "items") (rest src)) + ((= op "children") (if item (or (get item "children") (list)) (list))) + ((= op "field") (if item (or (get item (str (first (rest src)))) (list)) (list))) + (else (list)))))) + +;; ── template registry (recursion: a template may reference itself by name) ── +(define host/comp--tmpls (dict)) +(define host/comp--def-tmpl! (fn (name node) (dict-set! host/comp--tmpls name node))) + +;; ── the render-fold (the interpreter) ─────────────────────────────── +(define host/comp--render-all + (fn (nodes ctx) (reduce (fn (acc n) (str acc (host/comp--render n ctx))) "" nodes))) + +;; alt: render the FIRST branch whose `when` holds (or `else`) — recursive first-match so +;; a branch that legitimately renders empty isn't skipped. +(define host/comp--alt-pick + (fn (branches ctx) + (if (empty? branches) + "" + (let ((br (first branches)) (bh (str (first (first branches))))) + (cond + ((= bh "else") (host/comp--render (first (rest br)) ctx)) + ((= bh "when") (if (host/comp--pred? (first (rest br)) ctx) + (host/comp--render (first (rest (rest br))) ctx) + (host/comp--alt-pick (rest branches) ctx))) + (else (host/comp--alt-pick (rest branches) ctx))))))) + +;; each: eval source -> items; render template per item with :item bound + :depth+1 +;; (depth guard backstops runaway recursion; trees terminate naturally on empty source). +(define host/comp--each + (fn (src tmpl ctx) + (let ((depth (or (get ctx "depth") 0))) + (if (> depth 40) + "(max depth)" + (reduce + (fn (acc item) + (str acc (host/comp--render tmpl (merge ctx {"item" item "depth" (+ depth 1)})))) + "" (host/comp--source src ctx)))))) + +;; card leaf (proof: a labelled box; in the host this renders via the card-type's :template). +(define host/comp--card + (fn (ctype fields) + (str "
" + (reduce (fn (acc k) (str acc "" k ": " (str (get fields k)) " ")) "" (keys fields)) + "
"))) + +(define host/comp--render + (fn (node ctx) + (if (not (= (type-of node) "list")) + (str node) + (let ((h (str (first node))) (args (rest node))) + (cond + ((= h "seq") (host/comp--render-all args ctx)) + ((= h "row") (str "
" (host/comp--render-all args ctx) "
")) + ((= h "grid") (str "
" (host/comp--render-all args ctx) "
")) + ((= h "alt") (host/comp--alt-pick args ctx)) + ((= h "each") (host/comp--each (first args) (first (rest args)) ctx)) + ((= h "field") (str "" (host/comp--field (first args) ctx) "")) + ((= h "text") (str (first args))) + ((= h "card") (host/comp--card (str (first args)) (first (rest args)))) + ((= h "tmpl") (host/comp--render (get host/comp--tmpls (str (first args))) ctx)) + (else "")))))) + +;; public entry: render a composition node against a context environment. +(define host/comp-render (fn (node ctx) (host/comp--render node ctx))) diff --git a/plans/composition-objects.md b/plans/composition-objects.md new file mode 100644 index 00000000..726f7c3a --- /dev/null +++ b/plans/composition-objects.md @@ -0,0 +1,78 @@ +# Composition objects — a content-addressed, data-driven UI model + +Everything the system stores is an **object**: typed, content-addressed (`:cid`), in one graph. +"Post" was the blog's word; the unit is an object. A *document* is an object whose **body** is a +composition over other objects' CIDs. This is the cards-as-objects decision, generalised. + +## One mechanism: ordered, labelled forks + +An object forks into children via **labelled, ordered edges** (the relations engine + `order` on +the edge value + an optional `when`). There is no separate "composition system" — relations *are* +the forks. The **label** says what a fork means: +- **structural** (`contains`) → ordered, part of identity, rendered; +- **cross-cutting** (`tagged`, `related`, `author`) → loose links, not structural. + +Multiple relations from an object *are* its fork. No "multiple DAGs per object" — fork immediately; +differently-labelled forks (`body` vs `aside`) give named slots. **Join** = a child CID referenced +by two forks — free, because content-addressed. The whole structure is a **Merkle DAG** (git trees +/ IPFS / artdag): `:cid` = hash over `fields + contains-forks (child-CID + order + when)`. + +## The body is a tiny UI language (the render-fold is its interpreter) + +A body is a composition node. Four combinators + leaves + references: + +| node | meaning | strategy | +|------|---------|----------| +| `(seq …)` | **sequence** | render all (block), in order | +| `(row …)` / `(grid …)` | **layout** (par) | render all, side-by-side | +| `(alt (when P n) … (else n))` | **conditional** (or) | render the FIRST child whose `when` holds | +| `(each src tmpl)` | **iteration** (loop) | eval `src` → items; render `tmpl` per item (item bound) | +| `(ref CID)` | transclude | fetch object by CID, render its body | +| `(card TYPE fields)` | leaf | render via the card-type's `:template` (host/blog--instantiate) | +| `(tmpl NAME)` | **recursion** | a named template, may reference itself | + +`seq/row` = render-**all** passing children; `alt` = render-**first** passing child. So **and/or/choice +all come from one axis (`when` on forks) × the container's all/first strategy** — `Alt` isn't a new +node kind, it's "first" instead of "all". + +## The two fundamentals we designed IN + +1. **Recursion** — `(tmpl NAME)` may reference itself; `(each (children) (tmpl NAME))` renders trees + (comment threads, nested nav, the `/meta` type hierarchy itself). Terminates naturally when a + query runs dry; a **depth guard** in the context backstops it. +2. **The context is an environment, not a flat dict.** `when` reads it; `each` *extends* it + (`:item`). Make it extensible + reactive-ready and the two non-composition axes plug in with NO + new combinators: + - **Behaviour / interactivity** (Slice 9 lifecycles/effects) — a button references a behaviour; + - **Reactivity / local state** (the reactive runtime) — `alt(when local-state=active-tab)` is a + tabset, `alt(when accordion-open)` an accordion; a *live* `each` re-renders on data change. + The static render-fold becomes a live, interactive UI purely by making the context live. + +## The unifying property + +**The object's CID is its *definition* (the query, the template, every `when`-variant). The +*rendering* is the *execution* (which items, which branch, which context).** The object is the +program; the render is the run. One immutable content-addressed object encodes its whole +responsive/personalised/variant space; rendering picks the path. Render-fold and the Slice-9 +behaviour interpreter are the **same shape** — interpreters over content-addressed objects + the +decidable-core predicate set + the graph. The system converges on: objects + small interpreters. + +## What lives elsewhere (not composition primitives) + +Transclusion = a `ref` leaf. Sort/filter/limit/group = the *source query* language (Datalog). +`each` reconciliation keys = the item's CID (free). Empty / missing-CID = render-fold robustness +(the per-block guard). Async/streaming, events, local state = the behaviour + reactive axes. + +## Build roadmap + +1. **Keystone (this):** `lib/host/compose.sx` — the render-fold interpreter over seq/row/alt/each/ + ref/card/tmpl, with the context-as-environment, `when` predicates, and recursion + depth guard. + Self-contained proof: render one composed object two ways (auth on/off) + a recursive tree. +2. Wire it to objects: a document's `:body` is a composition node; `contains` forks carry order; + `host/blog-render` dispatches to the render-fold when `:body` is present (else the legacy + `sx_content` path). Card leaves render via the existing card-type `:template`. +3. `each` source = a graph query (`(query is-a Event)` → `host/blog-instances-of`) — data-driven. +4. Live context: route auth/device/locale into the context; reactive values later. +5. The typed importer decomposes Ghost Lexical into card objects + a `contains` body (cards-as- + objects), instead of one `sx_content` string. +6. The block editor edits the body (insert/reorder/`alt`/`each`) — the metamodel editor for content.