Cross-component signalling that rides the response of a wanderland-core pipeline. Boundaries emit classified crossings in the :signals:web:* namespace; the client fans them out through window.lanternBus to registered receivers. One mechanism, server to page.
The contract is name + args: the action name comes from the type_addr tail, the args come from the crossing payload. Same grammar the <oculus-fence> data-signals attribute uses; different producer.
A boundary inside a pipeline emits a typed signal — either via the Wanderland::Signal.emit(addr, **payload) convenience constructor or via Wanderland::Signal.new(type_addr:, payload:) directly. Whatever shape the boundary returns becomes the crossing's result field; downstream chain slots (collect_grid_state for grid routes, the route's own mapping boundary otherwise) project signal-classified crossings into the response's events slot.
class ArrivedAtStore
include Wanderland::Boundary
boundary :arrived_at_store, capabilities: [:web_signal]
def call(input)
Wanderland::Signal.emit(
":signals:web:refresh",
target: "story-board",
slug: input.find_or_fail("args.next_slug"),
section: input.find_or_fail("args.next_section")
)
end
end
A run may emit any number of signal crossings. Response order matches execution order.
{
"value": { "...": "context value projection — last-writer-wins state for rendering" },
"events": [
{ "type_addr": ":signals:web:refresh",
"result": { "target": "story-board", "slug": "dag-tutorial", "section": "scene-2" } }
],
"items": [],
"_trace": [ "/* every crossing the run produced, signed, in order — only when X-Wanderland-Trace is set */" ]
}
Each entry in events[] is a slice of a signal-classified crossing — its type_addr and result. The result carries whatever payload the boundary stuffed into the signal. _trace rides alongside events (not in place of) when the request sets the X-Wanderland-Trace header; default mode skips it to keep the wire lean.
The component that initiated the request walks events and fans the signals out onto window.lanternBus:
for (const ev of response.events || []) {
if (typeof ev.type_addr !== 'string') continue;
if (!ev.type_addr.startsWith(':signals:web:')) continue;
const action = ev.type_addr.slice(':signals:web:'.length);
window.lanternBus.emit(action, ev.result);
}
<wanderland-button> does this walk for every response. See Lantern Bus for the bus API and conventions. The initiating component has no responsibility beyond fanning out — it doesn't know which action ids exist, doesn't resolve targets, doesn't render UI. Receivers self-register at page load and decide what each action means.
The bus delivers each emit as a CustomEvent whose .detail carries the payload, so handlers read e.detail (or destructure ({ detail }) =>) to access the data.
Page-level handlers registered once, shipped with the layout so any pipeline emission can count on them. Each action is generic — target components own the interpretation of their payload.
Refresh a target. Mirrors <oculus-fence>'s data-signals="refresh:target" behavior. If the target exposes applySignal(name, payload), the whole payload is passed through and the component decides what to do with it. Otherwise falls back to .reload() or .refresh().
lanternBus.on('refresh', (e) => {
const payload = (e && e.detail) || {};
const el = document.getElementById(payload.target);
if (!el) return;
if (typeof el.applySignal === 'function') {
el.applySignal('refresh', payload);
return;
}
if (typeof el.reload === 'function') { el.reload(); return; }
if (typeof el.refresh === 'function') { el.refresh(); return; }
});
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
target |
string | yes | HTML id of the element to refresh |
| ... | any | no | Target-interpreted fields, passed through to applySignal |
Component-specific behavior lives on the target, not in the receiver. <oculus-node> implements applySignal('refresh', { slug, section, ... }) by updating its attributes and then reloading — the receiver never sees slug or section.
Call .run(params) on the target. params is the payload minus target. Two listener names registered for the same handler — matches <oculus-fence>'s execute / run aliasing.
const runHandler = (e) => {
const payload = (e && e.detail) || {};
const el = document.getElementById(payload.target);
if (!el || typeof el.run !== 'function') return;
const { target, ...params } = payload;
el.run(params);
};
lanternBus.on('execute', runHandler);
lanternBus.on('run', runHandler);
Transient page-corner card. Dismisses on click or after a timeout. Stacks in a document-scoped container managed by the handler so no component coordinates layout.
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
message |
string | yes | Body text |
level |
info / success / warn / error |
no | Visual accent; default info |
duration_ms |
number | no | Auto-dismiss after ms; default 5000; 0 → sticky |
action |
{ label, signal, payload? } |
no | Optional action button; clicking it calls lanternBus.emit(signal, payload ?? {}) — compose toasts with other receivers |
Open a modal form, collect user input, and chain the submission into downstream bus events. Building block for pipelines that pause for user input — ephemeral customization (pipe the answer into a toast, refresh, or another signal) and long-lived persistence (merge into context so a future run can pick it back up).
Payload:
{
"title": "Who are you?",
"prompt": "Just your name for now.",
"fields": [
{ "name": "who", "type": "text", "label": "Name", "default": "world",
"constraint": { "max_length": 100 } }
],
"submit_label": "Say hi",
"on_submit": [
{ "signal": "toast", "payload": { "message": "Hi, $values.who!" } },
{ "signal": "refresh", "payload": { "target": "greeting", "section": "$values.who" } }
],
"context_path": "session.visitor_name",
"resume": { "endpoint": "/games/abc-123/resume", "method": "POST" }
}
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | no | Heading above the form |
prompt |
string | no | Descriptive blurb |
fields[] |
array | yes | Form field specs. type ∈ text / textarea / number / boolean / select. select adds options: [{ value, label }]. constraint is an optional ShapeMatcher-style shape enforced client-side (max_length, min, max, pattern, …). |
submit_label / cancel_label |
string | no | Button labels |
on_submit[] |
array | no | Bus signals to fire on successful submission, in order. Each entry is { signal, payload }. Payload values support $values.<field> substitution pulled from the form submission. |
context_path |
string | no | Dot-path where the submission should merge into the wanderland context on resume. Persistence path. |
resume.endpoint / .method |
string | no | Where to POST the collected values to resume a paused pipeline. Omit for pure-client flows. |
Submission flow. On submit, the handler:
field.name.on_submit, walks the payload recursively and replaces any $values.<field> reference with the submitted value, then lanternBus.emit(entry.signal, substituted_payload). Signals fire in order.resume.endpoint is present, POSTs the values back so the server can resume the paused pipeline (the server decides whether to merge at context_path).on_submit nor resume is present, emits dialog-input:submitted with { values, context_path } on the bus and lets the page decide.on_submit and resume compose. Ephemeral toast plus server persistence is one payload.
$values substitution grammar.
$values — the whole submission; JSON-stringified if used in a string position$values.<field> — single field by name; strings pass through, non-strings JSON.stringify'd"Hi, $values.who!" → "Hi, alice!"<oculus-fence>'s $result.path rulesSame substitution engine as oculus-fence's data-signals; same mental model.
Idiomatic uses.
on_submit fires a toast with the answer inline. No server round-trip, no persistence. The pipeline that emitted the dialog has already finished; the dialog is decoration.context_path + resume.endpoint set, no on_submit. Server merges the answer into context and resumes. Next time the same session runs a pipeline, the value is already in context.on_submit and resume.Pause/resume story — the full-shape contract deferred for follow-up. A boundary that needs input emits :signals:stop:dialog-input (blocking) that halts the chain; the response carries the dialog spec and the request id; the client submits the filled form to resume.endpoint; the server re-hydrates context from storage, merges the user values at context_path, and resumes the pipeline. First cut is :signals:web:dialog-input (non-blocking) — pipeline finishes, client handles the dialog asynchronously via on_submit / resume.
New receivers are added the same way: a listener on window.lanternBus, a documented payload shape, and no DOM coupling to the emitter. Any compose service (sprout, lantern, the future wanderland-engine, anything behind the same Caddy) can emit these actions and reach the same receivers — the namespace is the contract.
Component-specific signal handling belongs on the component, not in the receiver library. A component opts in by exposing one of:
.reload() / .refresh() — fallback target for the generic refresh receiver.run(params) — target for execute / runapplySignal(name, payload) — richer hook; receives the full payload and decides how to interpret itThis keeps the receiver library small and domain-agnostic. The bus never knows a slug from a message — it just delivers the payload to whoever's listening.
<oculus-fence> data-signalsSame grammar (action + args), different origin. <oculus-fence> declares signals on the client side via attributes; server pipelines emit them via type_addr. Both flow through the same bus receivers. A refresh handler doesn't care which surface produced the event.