Rules governing the ClojureScript source emitted by Bareforge's export pipeline (
src/bareforge/export/*). These rules are distinct from Bareforge's own source-code style (see CLAUDE.mdCode stylefor that). The conceptual model — Field / Action / Trigger, the canonical-source-and-inline contract, and the bug- first test rule — lives in CLAUDE.mdExported ClojureScript code style. This file is the codegen spec proper, referenced from in-source comments by rule number.
-
The exported
app.frameworkis a minimal re-frame subset. It MUST support threereg-subarities:(reg-sub id handler-fn)— free-form handler(reg-sub id :-> key)— direct extraction from root db(reg-sub id :<- [source] :-> key)— derived from another sub Any generator feature that requires a new arity must be added to the framework first, with a node test covering it.
Canonical source + inline.
app.frameworkandapp.rendererare both slurped from their canonical files at build time —src/bareforge/export/framework.cljsandsrc/bareforge/export/renderer.cljs— viashadow.resource/inline, with onestr/replace-firstrewriting thensprefix to<app-ns>.framework/<app-ns>.renderer. New features land in the canonical.cljsfile (with normal syntax highlighting, compile-time checking, and namespace-level tests), not in a string literal insidecljs_project.cljs. Byte-level parity is pinned by two tests incljs_project_testthat reproduce thestr/replace-firstand assert equality against the emitted output; a re-introduced manual copy will fail them with a clean diff. -
Generated subs prefer the shortened shapes:
- Direct extraction (most common):
(rf/reg-sub ::cart-count :-> ::cart.db/cart-count) - Derived from another sub (only when truly derived):
(rf/reg-sub ::foo :<- [::bar] :-> ::ns/field)Only fall back to(fn [db] ...)when the logic cannot be expressed with:<-/:->.
- Direct extraction (most common):
-
No forward
declarein the generatedapp.framework. Order defs so each is defined before it is referenced. A blanket#"\(declare "regex assertion incljs_project_testenforces this across the emitted framework. Theapp.renderer(adapted from bare-demo) is exempt: its mutual recursion betweencreate-element/create-nodeandpatch-node!/patch-children!is genuine, and one side of each pair needs adeclare. -
The root
app.dbis a FLAT merge of each group'sdefault-db. Keys are fully namespaced by their group's db namespace (e.g.:app.cart.db/cart-count), so collisions are prevented without nesting. Do not introduce tier-1 slice subs or anapp.core.subsnamespace.Every named group auto-gets a locked
::id int? 0field at the head of its:fields.doc.ops/set-nameinserts it when a name becomes non-blank and strips it when the name is cleared. The inspector renders the row as read-only andremove-fieldrefuses to drop it.db.cljstherefore always emits::idfirst in thes/keys :reqvector, and template groups'::recordspec includes it. -
Hiccup prop maps on custom elements follow a strict shape:
- 0 props: omit the map entirely.
- 1 prop: inline —
[:x-button {:variant "primary"} ...]. - 2+ props: one prop per line, vertically aligned after the opening
{.
-
:requirevectors in generated files use dot-notation aliases that mirror the tail of the namespace ([app.cart.db :as cart.db],[app.cart.subs :as cart.subs]). No single-segment aliases for multi-segment namespaces. Only emit requires the file actually uses:[app.framework :as rf]is dropped from a view's:requirelist when the view has no let-bindings, no triggers, and no collection sub-groups (i.e. nothing that reaches forrf/queryorrf/dispatch). A view with nothing to require emits bare(ns app.<group>.views)with no:requireclause at all. -
Generated views are hiccup vectors consumed by the hand-written reconciler in
app.renderer. Never emit rawjs/document.createElementinterop or any JSX-style DSL. -
Files with nothing to emit are omitted, not written as empty placeholders. A group with no readable fields gets no
subs.cljs; a group with no events gets noevents.cljs. -
Event handling splits cleanly into three concepts. Keep them distinct:
- Field (
:fieldson a group node): a reactive state slot. - Action (
:actionson a group node): a named event handler that mutates a field in the SAME group.{:name :operation :target-field}. Operations::set :toggle :increment :decrement :clear :add :remove(:add=conj,:remove=filtervnot-equal; both work on vector fields). - Trigger (
:eventson an interactive descendant): fires an action on a DOM event.{:trigger :action-ref}, with:payloadoptional.:action-refis a fully qualified keyword like:app.cart.events/add-to-cart. When:payloadis absent (the v1 default), the generator walks up from the trigger node to the nearest enclosing template-instance record and dispatches that record as the single positional arg. Triggers outside any template dispatch with no args. An explicit:payloadvector is still honoured for legacy docs and special cases — see rule 17.
- Field (
-
Every declared field gets an auto-generated setter event
::<field>-changedin its group'sevents.cljs. Both auto setters and declared actions usetrim-v+path:(rf/reg-event ::cart-count-changed [rf/trim-v (rf/path ::cart.db/cart-count)] (fn [_ [new-cart-count]] new-cart-count)) (rf/reg-event ::add-to-cart [rf/trim-v (rf/path ::cart.db/cart-items)] (fn [v [x]] (conj v x)))
The framework MUST:
- expose
trim-vas a public var andpathas a public fn (variadic on key path segments), - support the 3-arity
(reg-event id interceptors handler)form, - implement interceptors with
:before/:afterphases running over a context{:db :event}, with:afterin reverse order (classic interceptor-chain semantics —path's:afterwrites the handler's return value back into the wider db). If a declared action uses the same name as an auto setter, the declared action wins (auto setter skipped).
Payload re-keying for of-group targets. When a declared action's target field is
:of-group G, the generated handler wraps its incoming payload with(rf/qualify-map x "<app-ns>.<G>.db")so the record's keys match the target group's spec. Affects every op that consumes the dispatched payload —:set,:add,:remove.qualify-mapis idempotent: payloads already in the right shape pass through unchanged. Example:cart.add-to-carttargetingcart-items :of-group "cart-item"dispatches a product record; the handler re-keys it to::cart-item.db/*beforeconj:(rf/reg-event ::add-to-cart [rf/trim-v (rf/path ::cart.db/cart-items)] (fn [v [x]] (conj v (rf/qualify-map x "app.cart-item.db"))))
Scalar-target actions (
:toggle,:increment,:decrement,:clear, or:set/:add/:removeon a non-:of-groupfield) are unchanged — no qualification needed. - expose
-
Triggers dispatch cross-group. The generated view:
- adds the action-ref's
:app.<owner>.eventsnamespace to its:requirelist, - for the implicit-payload default (no
:payloadon the trigger): dispatches the enclosing template-group record as the single arg. Template group view fns destructure their record arg as{:keys [...] :as record}sorecordis always in scope. - for the explicit-payload legacy path: binds every payload
field via
(rf/query [::<owner-subs>/<f>])in the enclosinglet(routing each field through the subs namespace of its declaring group via the field-owner index), and emits#(rf/dispatch [::<owner>.events/<action> <arg>…])where args are the let-bound symbols.
- adds the action-ref's
-
Computed fields (
:computed {:operation :source-field}on a::field-def) are derived, not stored. They have NO entry indefault-dband NO auto<field>-changedsetter. Their subscription is emitted as a derived re-frame sub:(rf/reg-sub ::cart-count :<- [::cart-items] :-> count)
v1 operation set —
:count-of,:sum-of,:empty-of,:negation,:join-on,:any-of,:filter-by.:first-of/:last-of/:lookup-inare not v1 ops — any of them in an older doc is a migration error.Simple-op extractors:
:count-of→count,:empty-of→empty?,:negation→not.:sum-ofover a collection of numbers →#(reduce + 0 %).:sum-ofwith a:project-field(the source holds records) →#(transduce (map ::<of-group>.db/<field>) + 0 %).Simple ops (
:count-of,:sum-of,:empty-of,:negation) reference only fields on their own group; cross-group reach is:join-on's job. A computed field may reference another computed field on the same group (chained computeds — e.g.has-items = (negation is-empty)).:any-ofis a multi-signal boolean OR over:source-fields; see rule 15.:filter-byis a multi-signal derived collection: combines the filtered:source-field(a local:of-groupcollection) with a scalar:filter-spec :search-fieldon the same group and returns items whose:match-fieldon the template record contains the search term. v1 supports one:match-kind(:contains-ci, case-insensitive substring); blank term is a pass-through. The generator emits a multi-signalreg-subthat reads via::<template>.db/<match-field>, so the filter-by field's:of-groupmust match the source collection's:of-group. Like other computed fields,:filter-byhas nodefault-dbentry and no auto setter.Because
:->is applied as an arbitrary function, the generated framework's 3-arity and 5-arityreg-subarms MUST call(extract-fn source), not(get source extract-fn). Keyword extractors still work (keywords are functions).Writes to a computed field are rejected at the inspector UI level: action target-field pickers exclude computed fields; the bindings UI disallows
:write/:read-writedirections on them.
x-grid columns is a CSS track list, not a column count. Pass
it as a real grid-template-columns value — "1fr auto auto",
"repeat(3, 1fr)", etc. — not as the number "3". BareDOM passes
the attribute through verbatim; a bare integer produces an invalid
template and each child ends up on its own row.
As a safety net, the generator coerces any x-grid columns value that
is a bare integer string (e.g. "3") to "repeat(N, 1fr)" at emission
time. Documents should still carry valid track lists — the coercion
exists for legacy docs and errant inspector input, not as a shortcut.
Wrappers are transparent. Only nodes that carry a non-empty :name
become groups. Unnamed containers (x-container, x-card,
x-gaussian-blur, x-grid, …) are decorative / layout wrappers. They
render as inline hiccup inside their enclosing group's view, or
directly inside app.core/app if they sit at root. The generator
walks recursively through such wrappers; wherever it meets a named
descendant, it emits (<group>.views/<group>) at that position and
stops descending — the named group owns its own subtree. Being a
direct child of root does not implicitly make a node a group.
-
Template groups and collection fields. Vectors of records are modelled by two explicit declarations:
- A template group has
:fieldsdescribing a record shape (plus a view template). It owns no state of its own. A group becomes a template group iff some other group's collection field points at it via:of-group "<this-group>". There is no explicit:kindflag; the template/stateful distinction is determined by:of-groupreferences alone. - A collection field is a
:vector-typed field-def carrying:of-group "<template-group>". It lives on a stateful group and owns the vector of records. Seed records come from the field's:default— there is NO pluralisation of group names and NO harvesting of seed data from duplicate canvas nodes.
At design time, the canvas iterates seeds — a template-instance node with a
:source-fieldpointing at a seed-backed collection renders one DOM clone per seed, with:text-field-bound descendants pre-substituted from each seed record.:source-subinstances stay as single placeholders (subs are runtime-only). Seesrc/bareforge/render/canvas.cljs/expand-templates.A template instance (a canvas node that belongs to a template group) names its data source with one of:
:source-field :<field-name>— resolved via the field-owner index to a sub on the owning stateful group's subs ns.:source-sub :<ns>/<name>— a qualified sub keyword, typically a computed join (:app.cart.subs/cart-with-products).
Export output:
- Template group
db.cljs: specs only — per-fields/defplus a::recordkey spec. Nodefault-db; not merged into the root. - Template group
subs.cljsandevents.cljs: NOT emitted. - Template group
views.cljs: view fn takes a record arg, destructured with fully-qualified:keys(rule 14). Nodes with:text-field :fooemit the plainfoosymbol. Trigger payload entries whose owner matches the template group read directly off the destructured record via the same symbol. - Stateful group with a collection field
:F :of-group "G":db.cljsemits(s/def ::F (s/coll-of ::G.db/record)), seeds{::F <:default>}indefault-db, and requires the template group'sdb.cljs(for the::recordspec).subs.cljsemits(rf/reg-sub ::F :-> ::<owner.db>/F).events.cljsemits an auto setter::F-changed(vector replace) alongside any declared:add/:removeactions. - Parent of the template instance iterates with
(for [p (rf/query [<sub-ref>])] (<ns>.views/<ns> p))— where<sub-ref>comes from:source-subor is built from:source-field+ the field-owner index.
- A template group has
-
Destructure collection records with fully-qualified
:keysand keep the whole map via:as record. The generated view MUST destructure the record arg as{:keys [::<db-alias>/<field> ::<db-alias>/<field> …] :as record}, NOT the{::<db-alias>/keys [field …]}shorthand:;; GOOD — explicit keys, plus :as record for implicit payload (defn cart-item [{:keys [::cart-item.db/id ::cart-item.db/title ::cart-item.db/price] :as record}] …) ;; BAD — shorthand form (defn cart-item [{::cart-item.db/keys [id title price]}] …)
Rationale: records carry fully namespaced keys (per rule 4), so the destructure should mirror that exactly rather than rely on the
::alias/keysexpansion sugar.:as recordis required so triggers with implicit payload can dispatch the whole map (see rule 11). -
Multi-signal subs are supported by the generated
app.framework.reg-subis variadic; any number of:<-inputs may precede either a:-> extract-fnor a plain handler fn. Single-input:->unwraps the value for backward compatibility; multi-input paths pass a vector:(rf/reg-sub ::joined :<- [::a] :<- [::b] (fn [[a b] _] (merge a b)))
-
:join-oncomputed fields emit a multi-signal sub that joins a local vector-of-ids (:source-field) against a template group's records (:join-target {:group-name :match-field :of-group}). The records live on the stateful group that owns a collection field pointing at the template via:of-group— the generator resolves that automatically. When:of-groupis set on the join-target, matching records are passed throughrf/qualify-mapto re-key them into the target group's db namespace. Required aliases: the owning group's<owner>.subs(for the:<-input) and the template's<target>.db(for the::<target>.db/<match-field>keyword used inside the handler). -
Explicit payload variants (legacy / advanced). The v1 inspector does not offer payload customisation — triggers it creates have no
:payloadkey and rely on the implicit record dispatch (rule 9). The generator still honours an explicit:payloadvector in the doc for docs authored before v1 or hand-edited via JSON. Entries come in three shapes:{:field :owner}— field reference (let-bound or destructured).{:literal <value>}— an EDN literal dispatched verbatim (e.g.true/falsefor hover/open flags).{:event-detail :key}— reads(.. e -detail -key)from the DOM event. When any entry is:event-detail, the generated handler wraps as(fn [^js e] (rf/dispatch …))instead of#(rf/dispatch …).
A trigger also accepts a
:prevent-default? truesibling flag. When set, the handler is wrapped as(fn [^js e] (.preventDefault e) (rf/dispatch …))so the component's built-in behaviour for the event is skipped — useful when db-driven state would otherwise race with the component's internal state mutation (e.g.x-popover-togglewhere the component would toggle its ownopenattribute after our dispatch, racing our render pass). -
When a bug is found in the generated output, add a failing test in
test/bareforge/export/against a known-good fixture before fixing the generator. -
Every template-group iteration emits a
display: contentswrapper. The generator MUST emit:[:div {:style "display: contents"} (for [p (rf/query [::owner.subs/<field>])] (<tpl>.views/<tpl> p))]
…never a bare
(for ...)form at the hiccup children position. Rationale: the emittedapp.rendererdiffs children positionally. Without a wrapper, a shrinking list's tail shifts into trigger- slotted siblings (x-icon, x-badge, etc.) and replace-branch fallout strands stale DOM — especially visible with<x-popover portal="true">and friends, which physically relocate their default-slot children on open and never re-snapshot. The wrapper gives the iteration its own stable position in the parent's children array and its own isolated child-space in which every element has the same tag (the template group's root), so the positional diff stays sound.display: contentskeeps the wrapper visually transparent: grid / flex / popover default-slot all lay out as if the wrapper weren't there. A node test incljs_project_testpins the emitted shape.The partner half of this contract lives in the reconciler. See
src/bareforge/export/renderer.cljsfor why it tracks children in a__bd_childrenarray on each parent (instead of reading live.childNodes) and anchorsreplaceChild/removeChildonnode.parentNode: components that relocate their own children (popover portals, future teleports) reconcile correctly because our array follows the nodes wherever they currently live. Do not simplify back to.childNodes-based diffing — Bug 3 in the audit branch is the cautionary tale.