Lesson 08 — The :custom-script escape hatch
Goal: by the end of this lesson you can express interactive behaviour the platform components don't cover — a custom hover effect, a one-off DOM tweak, integration with a 3rd-party JS library — without leaving the graph editor and without modifying any Clojure code.
Concepts introduced: :js-source type alias,
:custom-script, :wrap-custom-script, :dispatch-custom,
data-action="custom", data-custom-handler, escape hatch.
When you'd reach for this
Lesson 07 covered the 10 platform components and the two
built-in click handlers (navigate, submit-form). Real
sites usually need a sliver of behaviour outside that scope —
a tooltip on hover, a date-picker library, a confetti burst
on form submit, scroll-to-anchor, etc. Rather than every such
need becoming a feature request for the platform, the escape
hatch lets you drop in raw JS through the same fn-def surface
you use for everything else.
Two shapes, picked by where you need the JS to live:
| Where the JS runs | Use |
|---|---|
| Once, when the page loads (script setup) | :wrap-custom-script in the page's :scripts list |
| Per-click on a button | :dispatch-custom bound to the button's :attrs |
Page-level inline JS
:wrap-custom-script renders a JS body as
<script>body</script> for inclusion in a page's :scripts
list. Free arg :body is typed :js-source (the editor's code-editor widget — a
CodeMirror editor with JS syntax highlighting — kicks in).
Worked example — a script that decorates every [data-tip]
element on the page with its tooltip text:
{:name :my-tooltip-script
:parent :wrap-custom-script
:args {:body "document.querySelectorAll('[data-tip]').forEach(e => { e.title = e.dataset.tip; });"}}
Then in your page:
{:name :my-page
:parent :html-page
:args {:title "Tips demo"
:body :my-page-body
:scripts [:graphden-runtime-script-tag :my-tooltip-script]}}
Reload, hover over any [data-tip] element — the browser
tooltip appears.
When to use :custom-script instead
:wrap-custom-script takes the JS body inline as :body. If
the SAME body needs to appear both as a <script> AND as a
button's inline handler (a rare case), pull it into a named
:custom-script and reference it from both:
{:name :tooltip-init-body
:parent :custom-script
:args {:body "document.querySelectorAll('[data-tip]').forEach(e => { e.title = e.dataset.tip; });"}}
{:name :my-tooltip-script-tag
:parent :wrap-custom-script
:args {:body :tooltip-init-body}}
For the 90% case where the body is used once, skip the
intermediate :custom-script — bind :body directly on
:wrap-custom-script / :dispatch-custom.
Per-click inline handlers
:dispatch-custom builds the button-attrs shape
{:data-action "custom" :data-custom-handler <body>}. The
runtime's custom action handler reads data-custom-handler
on click and evaluates it as:
(new Function('btn', 'event', 'host', body))(btn, event, host);
So your JS body has three bindings in scope:
| Binding | What |
|---|---|
btn |
The clicked button element (has dataset.* for any extra data-* attrs you attach) |
event |
The click event (event.target, event.preventDefault(), …) |
host |
The dispatch host element (the root the dispatcher was bound to) |
Worked example — a "Wave at me" button that toggles a result
panel's emoji content with no server roundtrip (this is the
button shipped on /demo/contact):
{:name :my-wave-button
:parent :custom-button
:args {:label "Wave at me"
:body "var t = document.getElementById('contact-result'); t.textContent = (t.textContent.trim() === '👋') ? '' : '👋';"
:extras {:value {:type "button"}}}}
:custom-button (in web.components, Lesson 07) is the
convenience template — its :body slot is :js-source-typed,
which gives you the code-editor widget (CodeMirror, JS-highlighted)
instead of a single-line input.
If you'd rather see the underlying composition, the raw shape
is :button with :attrs built from
:dispatch-custom + :extras via :merge. The template just
packages it.
What happens on parse / runtime error
If your body is syntactically invalid JS or throws at
runtime, the custom action handler catches the error and
logs it to console.error — the click is a silent no-op,
sibling buttons keep working. The point: a typo in one
escape-hatch body doesn't take down the rest of your page.
custom handler: parse failed — Unexpected token '%'
custom handler: runtime error — Cannot read properties of null
Both surface in DevTools. There's no syntax check at graph-save time — the runtime JS parser is the only validator.
Editor widget
A slot typed :js-source (which :body is, on all three
escape-hatch entries) resolves through /api/value-form to a
multi-line <textarea rows="8"> that skips JSON.parse, so the
value round-trips as a plain string. The textarea is upgraded
in place to a CodeMirror 6 editor (editor-code.js) with JS
syntax highlighting, line numbers and search — the same widget
backs the :css-source, hiccup-EDN and JSON fields. The
underlying textarea stays in the DOM and receives every edit, so
form serialization is unchanged.
Try it: a script fn, in the editor
The smallest version needs no page at all. Select
:wrap-custom-script, ⋯ → "Extend", name the child — its card
shows one dashed body edge. Click its +, "Bind literal", and the value
form opens a code editor (syntax highlighting, bracket
matching) rather than a text box: the slot's type is
:js-source, and the form picks its widget from the type like
everywhere else. Type document.title = 'Graphden';, Save, then
run the fn. The result pane shows ‹script› tag with your JS
source under it — the same type-driven dispatch again, on the way
OUT this time: the fn's return type is :script-tag (a narrowing
of :hiccup-node — see Lesson 5), so the editor knows the value
is a page asset, not a visual component, and shows its source
instead of trying to render it. The value itself is ordinary
hiccup — ["script" {} "document.title = 'Graphden';"] — ready
to sit in a page's :scripts list. The JS runs in the browser
when a page carrying that tag loads — running the fn only builds
the tag.
Try it: extend /demo/contact
The contact-form demo already ships one escape-hatch button. Add a second that, on click, fills the message textarea with a canned template:
{:name :_contact-demo-fill-template-button
:parent :custom-button
:args {:label "Use template"
:body "document.querySelector('textarea[name=message]').value = 'Hi, I\\'d like to know more about ...';"
:extras {:value {:type "button"}}}}
Append it to :_contact-demo-page-body's :children, run
bb rebuild, reload /demo/contact. Click "Use template" —
the textarea fills.
What you've now got
| Layer | What | Lessons |
|---|---|---|
| Graph model | fn-defs, slots, bindings, types, effects | 1–7 |
| Process | branches, executing fns, services | 8–10 |
| Code re-use | packages | 11 |
| User-facing UI | components, dispatch, page routes | 12 |
| Escape hatch | :custom-script / :dispatch-custom |
13 |
| Distribution | publish, install, update, fork | 14 |
Lesson 29 continues from packages (Lesson 28): once you've authored a namespace, it shows how to publish it as a versioned artifact (the namespace's ⬆ action) and install / update / fork it across branches from the packages chip on the Build surface.
Multi-tenancy (multiple users hosting their own sites on one graphden instance, each with their own deploys / secrets / auth-isolated routes) is a separate future phase. Today every fn-def in this graphden installation shares the same surface; Lesson 07 covers how to mount your own page routes alongside the editor's, Lesson 08 gives you the JS escape hatch.