Lesson 07 — 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 ships two groups of primitives, each a thin
fn-def over :hiccup that pins the variable bits (label,
content, children) as slots and inherits an optional :attrs
map you can extend.
| fn-def | Renders | Required slots | Optional |
|---|---|---|---|
:button |
<button>label</button> |
:label |
:attrs |
:input |
<input/> |
— | :attrs |
:textarea |
<textarea>content</textarea> |
:content |
:attrs |
:select |
<select>options...</select> |
:options |
:attrs |
:option |
<option>label</option> |
:label |
:attrs |
:checkbox |
<input type="checkbox"/> (type merged in) |
— | :attrs |
:form |
<form>children...</form> |
:children |
:attrs |
:link |
<a href=...>label</a> |
:href, :label |
:attrs |
:image |
<img src=... alt=.../> |
:src, :alt |
:attrs |
:card |
<div class="card">children...</div> |
:children |
:attrs |
And the text/layout set:
| fn-def | Renders | Required slots | Optional |
|---|---|---|---|
:heading |
<h1>..<h6> by :level |
:level (1-6), :content |
:attrs |
:paragraph |
<p>children...</p> |
:children |
:attrs |
:stack |
<div class="stack"> (vertical flex) |
:children |
:attrs |
:row |
<div class="row"> (horizontal flex) |
:children |
:attrs |
:nav-bar |
<nav>children...</nav> |
:children |
:attrs |
:unordered-list |
<ul>children...</ul> |
:children |
:attrs |
:list-item |
<li>children...</li> |
:children |
:attrs |
:table |
<table>rows...</table> |
:children |
:attrs |
:table-row |
<tr>cells...</tr> |
:children |
:attrs |
:table-cell / :table-header-cell |
<td> / <th> |
:children |
:attrs |
:field-label |
<label>children...</label> |
:children |
:attrs |
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 chooser offers
"New from template…" — pick a component from the
type-filtered palette, name the instance, and it is created
(with the component as its parent) and appended in one step;
its free args then show on the canvas ready to bind.
:attrs is the inherited slot from :hiccup. Bind it to a
keyword-map to add :class / :id / :placeholder / etc.
For :link, :image, :card, and :checkbox the
required-or-default slots are merged on top of caller-supplied
:attrs, so you can't accidentally lose the platform shape.
Try it: the smallest example
Open the editor, hit "New fn-def", and create:
{:name :my-run-button
:parent :button
:args {:label "Run"}}
Execute it. The result hiccup 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 04); 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"].
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. |
custom |
Evaluates data-custom-handler as (btn, event, host) => … — the escape hatch (see Lesson 08). |
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 "Default styling" below).
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 (or bb deploy if your DB is dirty). Visit
http://localhost:9002/about.
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.
Default 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 that you append to :head.
Your own stylesheet, from the graph
Two ways to add 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.
If you don't want the default stylesheet — pass
:head {:value []} and the page gets no styling beyond
browser defaults.
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 ~700 KB and initialises the graph renderer + WebSocket subscriptions that crash a non-editor page./assets/graphden-runtime.jsis the minimal subset (~9 KB) — just the dispatcher + built-in handlers.
A full page: the contact-form demo
app.contact-demo (shipped) composes the ten primitives, the
DSL, 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
:style "width:100%;padding:8px;margin:6px 0"}}}}
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 1–11 covered the graph model. Lesson 07 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 08's :custom-script block.
Multi-tenancy — multiple users hosting their own sites on
one graphden instance, with their own deploys, secrets, and
auth-isolated routes — is a separate future phase. Today's
mount-point is the single shared :all items list (you edit
the EDN to add a route).