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 <noreply@anthropic.com>
5.0 KiB
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
- Recursion —
(tmpl NAME)may reference itself;(each (children) (tmpl NAME))renders trees (comment threads, nested nav, the/metatype hierarchy itself). Terminates naturally when a query runs dry; a depth guard in the context backstops it. - The context is an environment, not a flat dict.
whenreads it;eachextends 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 liveeachre-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
- Keystone (this):
lib/host/compose.sx— the render-fold interpreter over seq/row/alt/each/ ref/card/tmpl, with the context-as-environment,whenpredicates, and recursion + depth guard. Self-contained proof: render one composed object two ways (auth on/off) + a recursive tree. - Wire it to objects: a document's
:bodyis a composition node;containsforks carry order;host/blog-renderdispatches to the render-fold when:bodyis present (else the legacysx_contentpath). Card leaves render via the existing card-type:template. eachsource = a graph query ((query is-a Event)→host/blog-instances-of) — data-driven.- Live context: route auth/device/locale into the context; reactive values later.
- The typed importer decomposes Ghost Lexical into card objects + a
containsbody (cards-as- objects), instead of onesx_contentstring. - The block editor edits the body (insert/reorder/
alt/each) — the metamodel editor for content.