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-rendered which 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:

  1. <script src="/assets/graphden-runtime.js"> — loads the dispatcher + built-in handlers (submit-form, navigate, custom).
  2. An inline <script> calling bindActionDispatch(document.body) on DOMContentLoaded so 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.js is 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:

  1. :_contact-demo-email-input — :parent :input, :attrs bound to a literal {:type "email" :name "email" ...}.
  2. :_contact-demo-message-textarea — same shape for textarea.
  3. :_contact-demo-submit-button — :parent :submit-button, :label "Send", :extras {:data-target "#contact-result"}.
  4. :_contact-demo-result-panel — empty <div id="contact-result">.
  5. :_contact-demo-form — :parent :form, children = the four items above in order.
  6. :_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.
  7. :_contact-demo-page-handler — one fn-def with :parent :html-page-handler pinning :title / :body / :head / :scripts. That's the WHOLE page handler.
  8. :_demo-contact-{get,post}-data, :_demo-contact-methods, :demo-contact — the method-map merge so GET and POST share the /demo/contact path.

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-stylesheet const wrapped in :wrap-custom-style, appended to the page's :head list:

    {: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-route and <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/css with no cache directives, so an edit in the editor shows on the next reload.