Lesson 10 — Composing pages from components
Goal: by the end of this lesson you can build a working
HTML page — header, form, submit button, response panel —
and mount it as a route, entirely by composing fn-defs from
the platform's web.components library and the app.page
templates.
Concepts introduced: web.components, :button, :input,
:textarea, :select, :option, :checkbox, :form,
:link, :image, :card, the text/layout set (:heading,
:paragraph, :stack, :row, :nav-bar, :unordered-list,
:list-item, :table, :table-row, :table-cell,
:table-header-cell, :field-label), convenience templates
(:submit-button, :click-button, :navigate-button,
:custom-button), :dispatch-action, data-action,
/assets/graphden-runtime.js, submit-form handler,
navigate handler, :html-page-route, :html-page-handler,
:html-page-rendered, :stylesheet-route,
:custom-stylesheet, :graphden-runtime-scripts,
:graphden-page-head, /assets/graphden-components.css.
The starter component library
web.components is a namespace of thin fn-defs over :hiccup:
each pins the variable bits (label, content, children) as slots
and inherits an optional :attrs map you can extend. The ten
form primitives are :button, :input, :textarea, :select,
:option, :checkbox, :form, :link, :image and :card;
a text/layout set (:heading, :paragraph, :stack, :row,
:nav-bar, lists, tables, :field-label) covers the rest of a
page. Browse the namespace in the Explorer — each row's
description says what it renders and which slots it takes.
For example, a two-level page skeleton:
{:name :my-about-body
:parent :stack
:args {:children [{:parent :heading
:args {:level 1 :content "About us"}}
{:parent :paragraph
:args {:children ["We build things."]}}]}}
You don't have to type these compositions by hand: on any
hiccup :children chain, the + button's Append fn-ref opens
the picker filtered by the chain's element type — the component
library is its Compatible section. Append the component itself,
then ⋯ → + Extend on its card: a named instance (with the
component as its parent) takes the component's place in the chain,
you stay on the page's canvas, and the instance's free args show on
its card ready to bind (extend in place, lesson 03). The same two
moves work on ANY slot, not just a list.
:attrs is the inherited slot from :hiccup. Bind it to a
keyword-map to add :class / :id / :placeholder / etc.
Components with a platform shape pre-merge it, in two directions:
:link and :image put their required :href / :src + :alt
on top of your :attrs, so you can't lose the shape; :card and
:checkbox put their default {:class "card"} /
{:type "checkbox"} underneath, so your :attrs win on conflict
(open :_card-attrs and :_link-attrs in the Explorer to see the
two :merge orders).
Try it: the smallest example
Create this fn-def (the click-by-click editor version follows):
{:name :my-run-button
:parent :button
:args {:label "Run"}}
Run it (⋯ → ▶ Run). The Run pane recognises hiccup and renders the
component in a small frame — a live <button>, in the same
stylesheet a served page loads (/assets/graphden-components.css).
Click it: nothing happens yet, because no handler is bound — that
comes below. Raw under the frame shows the value itself:
["button", "Run"], keywords come back as strings — which as a
value is [:button "Run"]. You just built your first component
composition.
In the editor the same thing is two clicks. Select :button,
⋯ → "Extend", name the child — and look at the two lighter,
dashed edges hanging off its card: label and attrs. A
component's inputs reach a descendant as propagated free args
(Lesson 05); there is no hiccup literal anywhere with a label
inside it — the structure IS the composition, and the varying
positions in it are these edges. They bind exactly like any other
argument: click the + at the end of the label edge, "Bind
literal", type Run. The dashed edge becomes a bound value. A
list-typed input (children on any container) opens the append
flow instead, because a container holds items rather than one
value. The lighter/dashed rendering is provenance, not a
different mechanism: the deeper an input comes from, the lighter
its edge — hover it to see where it lives.
Now extend it with caller attrs:
{:name :my-styled-button
:parent :button
:args {:label "Save"
:attrs {:class "primary" :data-cy "save"}}}
Execute. Result: [:button {:class "primary" :data-cy "save"} "Save"] —
and in the frame, the stylesheet's primary look: button.primary is
the same blue as a form's submit button (graphden-components.css
styles it since 2026-09-13; before that the class was data with no
visible effect).
Wiring a click handler
Click behaviour rides on data-action="X" attributes that a
small JS dispatcher routes to a registered handler. The platform
pre-registers three handlers:
data-action |
Behaviour |
|---|---|
navigate |
Reads data-href, sets window.location.href. |
submit-form |
Finds the nearest <form> ancestor, POSTs its fields, swaps the response into data-target (CSS selector) or back into the form. |
The third, custom, evaluates an inline JS body — that escape
hatch is Lesson 11's.
Four convenience templates cover the common cases — each pre-
wires the matching data-action so you only think about the
visible bits:
| Template | Free args | Renders |
|---|---|---|
:submit-button |
:label, :extras |
<button data-action="submit-form" type="submit">label</button> |
:navigate-button |
:label, :href, :extras |
<button data-action="navigate" data-href="...">label</button> |
:click-button |
:label, :action, :extras |
<button data-action="<your-action>">label</button> |
:custom-button |
:label, :body, :extras |
<button data-action="custom" data-custom-handler="...">label</button> |
Example — a button that takes the user to /about:
{:name :goto-about
:parent :navigate-button
:args {:label "About"
:href "/about"
:extras {:value {}}}}
Result hiccup:
[:button {:data-action "navigate" :data-href "/about"} "About"]
:extras is the catch-all for extra attrs (:class, :id,
:type "button", per-handler data-* payloads). Caller's
:extras win on conflict (Clojure merge semantics) so you
can always override platform defaults.
The raw form
The convenience templates are sugar over a :merge +
:dispatch-action chain on :button's :attrs. If you need
something the four templates don't cover (a handler that needs
multiple data-* attrs the template doesn't expose, a button
that combines two action sources), build it yourself:
{:name :my-fancy-button
:parent :button
:args {:label "Save"
:attrs {:parent :merge
:args {:maps [{:parent :dispatch-action
:args {:action "submit-form"}}
{:value {:type "submit"
:data-target "#result"
:data-tracking-id "save-cta"}}]}}}}
The :dispatch-action atom (in web.runtime) builds the
{:data-action <name>} half; the literal map adds the rest.
Templates package this pattern.
Adding a page route
Components give you HICCUP. To serve a hiccup tree as an HTML
page at a URL you need: an <html>/<head>/<body> wrapper,
a hiccup-to-string render, a 200 OK response with text/html
content-type, and a route binding. The app.page module
collapses this 4-step chain into one template:
{:name :my-about-page
:parent :html-page-route
:args {:path "/about"
:title "About"
:body :my-about-body ; a fn-def returning hiccup
:head :graphden-page-head ; default stylesheet
:scripts {:value []}}} ; no JS needed for this page
Five pins — :path, :title, :body, :head, :scripts —
get you a working reitit-shaped route entry. The template
(:html-page-route in app/page/fns.edn) wraps :html-page
→ :render-hiccup → :html-ok-response → :get-route so you
don't have to know that chain exists.
:graphden-page-head is a drop-in :head bundle that includes
the components stylesheet <link> (default styling for
button/input/form/etc — see the styling appendix at the
end of this lesson).
Pass {:value []} if you want no stylesheet.
To mount it, edit resources/packages/app/route-groups/fns.edn
and append :my-about-page to :all's :items. Run
bb rebuild, then visit http://localhost:9002/about. Contributor agents
use their claimed worktree's bb wt up and its printed URL. A routine
package edit does not require truncating the database.
When the simple template isn't enough
:html-page-route is GET-only. For other shapes drop down:
- Same path, multiple methods (e.g. GET + POST on
/contact): use:html-page-handler(just the Ring handler — no route wrap) on the GET side of a method-map merge. The contact-form demo (app/contact-demo/fns.edn) does this; copy:_demo-contact-{get,post}-data+:_demo-contact-methods+:demo-contact. - Non-Ring sinks (write the rendered HTML to a file,
return it as an email body): use
:html-page-renderedwhich returns the raw text.
All three templates expose the same :title / :body /
:head / :scripts free args — pick the layer you need.
Wiring the runtime
If your page has any data-action="..." buttons (a form
submit, a navigate, an inline custom handler), bind
:scripts to :graphden-runtime-scripts:
{:name :my-contact-page
:parent :html-page-route
:args {:path "/contact"
:title "Contact us"
:body :my-contact-body
:scripts :graphden-runtime-scripts}} ; ← that's it
:graphden-runtime-scripts (in app/editor/fns.edn) is a
two-element list:
<script src="/assets/graphden-runtime.js">— loads the dispatcher + built-in handlers (submit-form,navigate,custom).- An inline
<script>callingbindActionDispatch(document.body)onDOMContentLoadedso the runtime starts routing clicks.
After this any button in :my-contact-body whose :attrs
were built with :dispatch-action / :dispatch-custom /
submit-form will work — no extra wiring.
Why not
/assets/editor.js? That bundle is the whole editor and initialises the graph renderer + WebSocket subscriptions that crash a non-editor page./assets/graphden-runtime.jsis the minimal subset — just the dispatcher + built-in handlers.
A full page: the contact-form demo
app.contact-demo (shipped) composes the form primitives, the
web.runtime attrs builders (:dispatch-action /
:dispatch-custom) and the page templates into a working
contact form.
Visit /demo/contact in the running editor; the page is:
┌──────────────────────────────────────────────────────┐
│ Contact us │
│ │
│ Demo — every element on this page is composed │
│ from web.components + web.runtime fn-defs. │
│ │
│ [you@example.com .....................] │
│ [Tell us how we can help.........] │
│ [ ] │
│ │
│ [ Send ] │
│ │
│ (← submit response swaps in here) │
└──────────────────────────────────────────────────────┘
Source: resources/packages/app/contact-demo/fns.edn. The
shape:
:_contact-demo-email-input—:parent :input,:attrsbound to a literal{:type "email" :name "email" ...}.:_contact-demo-message-textarea— same shape for textarea.:_contact-demo-submit-button—:parent :submit-button,:label "Send",:extras {:data-target "#contact-result"}.:_contact-demo-result-panel— empty<div id="contact-result">.:_contact-demo-form—:parent :form, children = the four items above in order.:_contact-demo-page-body— header + intro + form + result panel wrapped in a sized<div>. Plus an outside-the-form:_contact-demo-custom-button(:parent :custom-button) demonstrating the escape hatch.:_contact-demo-page-handler— one fn-def with:parent :html-page-handlerpinning:title/:body/:head/:scripts. That's the WHOLE page handler.:_demo-contact-{get,post}-data,:_demo-contact-methods,:demo-contact— the method-map merge so GET and POST share the/demo/contactpath.
Try it: extend the demo
Add a name field. Edit :_contact-demo-form's :children
list to prepend an extra input:
{:name :_contact-demo-name-input
:parent :input
:args {:attrs {:value {:type "text" :name "name"
:placeholder "Your name" :required true}}}}
No inline :style — the components stylesheet already styles
input.
Then update :_contact-demo-form :children to
[:_contact-demo-name-input :_contact-demo-email-input :_contact-demo-message-textarea :_contact-demo-submit-button].
bb rebuild and reload /demo/contact — the name field
appears above the email, submit still works, the server's
thanks partial swaps in.
What's next
Lessons 01–09 covered the graph model. This lesson turns it on
itself: every element on a user-facing page is a fn-def, the
dispatch is graph-visible, the response is a fn-def too. The
escape hatch for the 20% of behaviour the components don't
cover (a custom hover effect, a one-off computed style) is
Lesson 11's :custom-script block.
Editing the :all list needs a rebuild; to mount a page without
one — and on the cloud, where the EDN isn't yours to edit —
publish the fn as an app (Lesson 30).
Appendix: styling
:graphden-page-head injects a <link rel="stylesheet" href="/assets/graphden-components.css?v=…"> tag into <head>.
The stylesheet (resources/packages/app/editor/components.css)
uses tag-level selectors (button, input, textarea, form,
a, img, h1-h3) so the platform components get sensible
defaults — padding, focus rings, primary-button color for
type="submit", form gap — without any inline :style attrs at
the call site. CSS design tokens (--gd-primary-bg,
--gd-radius, etc.) live at the top of the file; re-theme by
overriding them in your own stylesheet appended to :head. Pass
:head {:value []} and the page gets no styling beyond browser
defaults.
Two ways to add your own CSS without leaving the editor:
-
Inline — a
:custom-stylesheetconst wrapped in:wrap-custom-style, appended to the page's:headlist:{:name :my-theme :parent :custom-stylesheet :args {:body ".card { border-width: 2px; }"}} {:name :my-theme-style-tag :parent :wrap-custom-style :args {:body :my-theme}} -
Served — mount the same body at its own URL with
:stylesheet-routeand<link>it from any number of pages:{:name :my-styles-route :parent :stylesheet-route :args {:path "/styles.css" :css :my-theme}}The route serves
text/csswith no cache directives, so an edit in the editor shows on the next reload.