Wanderland

Wanderland Web Signals

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.

Server side

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.

Response shape

{
  "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.

Client side

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.

Receiver library

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

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.

execute / run

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);

toast

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

dialog-input

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. typetext / 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:

on_submit and resume compose. Ephemeral toast plus server persistence is one payload.

$values substitution grammar.

Same substitution engine as oculus-fence's data-signals; same mental model.

Idiomatic uses.

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.

Extension

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:

This 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.

Relationship to <oculus-fence> data-signals

Same 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.

Tracking case