Event-Channel Relations
PinePaper’s event-channel relations let you author interactive state machines declaratively — clicks, keyboard input, timed transitions, mutually-exclusive groups, all expressed as addRelation calls. Unlike the continuous relations (orbits, follows, etc.) that evaluate every frame, event-channel relations are edge-triggered: they fire on discrete events and propagate through a frame-coherent dispatch queue.
The same graph that drives visual behavior also drives WAI-ARIA semantics and keyboard navigation through PinePaper’s A11yShadowTree pattern matcher (see Accessibility patterns below).
The pp:Event item
Events are first-class items in the registry. Create them like any other shape:
const clickedEvent = app.createEvent('button_clicked');
// or
const clickedEvent = app.create('event', { name: 'button_clicked' }).data.id;
pp:Event items have no visual rendering by default — they appear as small badge nodes in the editor so you can see and connect them, but they don’t contribute to exports.
Producers — pointer & keyboard → event
These relations fire an event when a pointer or keyboard interaction happens on the source item.
| Relation | When | Params |
|---|---|---|
on_click_fire |
Source clicked | { payload? } |
on_pointer_enter_fire |
Pointer enters source bounds | { payload? } |
on_pointer_exit_fire |
Pointer leaves source bounds | { payload? } |
on_key_fire |
Key pressed (focus-gated by default, or global) | { key, modifiers?, global?, preventDefault? } |
// Click a button → fire its event
app.addRelation(buttonId, eventId, 'on_click_fire');
// Press Enter (when the button has focus) → fire its event
app.addRelation(buttonId, eventId, 'on_key_fire', { key: 'Enter' });
// Global Escape → close a modal (regardless of focus)
app.addRelation(canvasId, closeEventId, 'on_key_fire', {
key: 'Escape', global: true,
});
Reactions — event → side effect
When an event pulses, its reactions apply side effects to their target items.
| Relation | What it does | Params |
|---|---|---|
on_event_set_property |
Sets item[property] = value |
{ property, value } |
on_event_set_property_from_template |
Sets item[property] to a template-interpolated string. {key} tokens resolve from item.data[key] (default) or item[key] (with source:'item'). Pair with on_event_increment on the same channel to drive live counters. |
{ property, template, source? } |
on_event_set_visibility |
Sets item.visible |
{ visible } |
on_event_set_color |
Sets fillColor or strokeColor |
{ color, which? } |
on_event_set_data |
Sets item.data[property] |
{ property, value } |
on_event_increment |
Numeric increment on item.data[property] |
{ property, by? } |
on_event_toggle |
Boolean flip of item.visible or item.data[property] |
{ property? } |
on_event_set_active |
Mutex activation via exclusive_group |
(no params; uses target’s group membership) |
on_event_add_relation |
Meta — graph modifies itself by adding a relation | { type, target?, params } |
on_event_remove_relation |
Meta — removes a relation by type | { type, target? } |
on_event_fire_after |
Delayed pulse of another event | { delay, timeline? } |
// Click → set a target's color
app.addRelation(buttonId, evtId, 'on_click_fire');
app.addRelation(evtId, targetId, 'on_event_set_color', { color: '#ef4444' });
// Click → increment a counter (data field, not yet visible)
app.addRelation(evtId, displayId, 'on_event_increment', { property: 'score', by: 10 });
// Click → live-update a visible label from the incremented data
//
// Two reactions on the same channel: increment runs first (registration
// order), then the template reads the post-increment value. Pair these
// with on_event_increment to make any data field show up as text.
app.addRelation(evtId, displayId, 'on_event_set_property_from_template', {
property: 'content',
template: 'Score: {score}',
});
Template interpolation rules
on_event_set_property_from_template takes a template string with {key} tokens and writes the resolved string to item[property]. The substitution rules are deliberately small — we picked template interpolation, not an expression language:
{key}matches a valid identifier (letters, digits,_,$; must start with a letter,_, or$).{x + 1}does not evaluate — it stays as the literal{x + 1}in the output.- Source defaults to
item.data[key]. Passsource: 'item'to read top-level item properties (item.opacity,item.position.xvia dotted keys is not supported — flatten intodatafirst). - Missing keys,
null, andundefinedresolve to an empty string for stable output.0andfalseare preserved as'0'and'false'. - Registration order matters when chaining: if you
incrementfirst and thenset_property_from_template, the template sees the post-increment value. Reverse the order and the template sees the prior value (one pulse behind).
Mutex — exclusive_group
Declares that two items share a slot — at most one is active at a time. Group members get a data._isActive boolean that setActive flips, with :enter and :exit events pulsing on every transition.
// Three buttons share the 'tabs' group (pairwise edges form the group)
app.addRelation(tab1, tab2, 'exclusive_group', { groupName: 'tabs' });
app.addRelation(tab2, tab3, 'exclusive_group', { groupName: 'tabs' });
// Each tab activates itself on click
app.addRelation(tab1, evt1, 'on_click_fire');
app.addRelation(evt1, tab1, 'on_event_set_active');
// (repeat for tab2, tab3)
// Reveal panels when their tab activates
app.addRelation(tab1, panel1, 'on_enter_set_visibility', { visible: true });
app.addRelation(tab1, panel1, 'on_exit_set_visibility', { visible: false });
app.setActive(itemId) is the public API; activating an item deactivates any sibling that’s currently active.
Edge triggers — on_enter_* / on_exit_*
When an exclusive_group member becomes active, its :enter channel pulses; when it loses active state, :exit pulses. Reactions on these channels mirror the on_event_* family with the prefix swapped — on_enter_set_visibility, on_exit_set_property, etc. They accept the same params as their on_event_* counterparts.
// When tab1 becomes active, reveal its panel
app.addRelation(tab1, panel1, 'on_enter_set_visibility', { visible: true });
// When tab1 loses active state, hide it
app.addRelation(tab1, panel1, 'on_exit_set_visibility', { visible: false });
Delayed pulses — on_event_fire_after
Schedule a delayed pulse without imperative setTimeout code. Two timing modes:
| Mode | When fires | Use case |
|---|---|---|
wall (default) |
After delay real-world ms |
“Cleanup the effect after 2 seconds” |
canvas |
When playbackTime reaches now + delay/1000 |
“At t=2s of this animation the state changes” |
// Wall-clock: 2 real seconds after burst event, fire cleanup
app.addRelation(burstEvent, cleanupEvent, 'on_event_fire_after', {
delay: 2000,
});
// Canvas-time: fires when the timeline cursor reaches 1.5s after the
// source pulse. Pauses with timeline pause, seeks with timeline seek.
app.addRelation(startEvent, beatEvent, 'on_event_fire_after', {
delay: 1500, timeline: 'canvas',
});
Both modes track pending fires on the relation’s params; removeRelation cancels any scheduled-but-not-yet-fired pulses.
Meta-relations — the graph modifies itself
on_event_add_relation and on_event_remove_relation let an event reaction add or remove arbitrary relations on the target. This unlocks timed effects with auto-cleanup:
// Click → attach a sparkle effect → 2 seconds later, remove it
app.addRelation(buttonId, burstEvt, 'on_click_fire');
app.addRelation(burstEvt, targetId, 'on_event_add_relation', {
type: 'effect_sparkle',
});
app.addRelation(burstEvt, cleanupEvt, 'on_event_fire_after', { delay: 2000 });
app.addRelation(cleanupEvt, targetId, 'on_event_remove_relation', {
type: 'effect_sparkle',
});
The whole chain is declarative — no imperative setTimeout, no event handlers in the scene.
Dispatch semantics
Event pulses are frame-coherent, not synchronous:
pulseEvent(id)pushes a{ eventId, payload }entry onto an internal queue.- The frame loop calls
_drainEventQueueonce per frame, before per-frame callbacks. - Listeners fire in registration order. If a listener pulses a downstream event, that pulse queues into the next frame’s batch — no synchronous recursion.
This gives deterministic ordering even when state-machine chains span multiple steps. A click → activate → reveal cascade takes ~3 frames to settle (one per chain link); at 60Hz that’s ~50ms, imperceptible to users but predictable for testing.
Accessibility patterns
PinePaper’s A11yShadowTree runs a pattern matcher over the relation graph after every scene load and every relation change. When a canonical interaction pattern is detected, the matcher promotes the matching items in the accessibility shadow DOM with the right WAI-ARIA roles and keyboard interactions — derived from the relation shape, not from aria-* attributes in the template.
| Pattern | Detection signature | Output |
|---|---|---|
| Tablist | exclusive_group + on_enter_set_visibility(visible:true) on ≥2 members |
role="tablist" / tab / tabpanel, aria-selected, aria-controls, roving tabindex, Arrow/Home/End/Enter/Space keyboard |
| Accordion | on_click_fire → on_event_toggle(property:'visible') on ≥2 (header, panel) pairs, NOT in any exclusive_group |
role="button" + aria-expanded on headers, role="region" + aria-labelledby on panels, Arrow Up/Down/Home/End/Enter/Space keyboard |
| Menubar | menubar_group on ≥2 items, NOT in any tablist |
role="menubar" wrapper + role="menuitem" on each, roving tabindex, horizontal Arrow Left/Right + Home/End + Enter/Space (no auto-activation on arrow — focus moves, activation only on Enter/Space/click) |
The shape is the contract. Add the relations that compose a tablist, you get the ARIA + keyboard for free. The author writes no aria-* attributes and no keyboard listeners — the matcher derives both from the relation graph the author already authored for the visual behavior.
Worked example — tabs
The canonical demo (Tabs (from relations) template) is built from these primitives:
// 3 buttons + 3 events + 3 panels
const buttons = ['tab_1', 'tab_2', 'tab_3'].map(/* create rect */);
const events = ['evt_1', 'evt_2', 'evt_3'].map(/* createEvent */);
const panels = ['panel_1', 'panel_2', 'panel_3'].map(/* create rect */);
// Mutex (pairwise edges form the group)
app.addRelation(buttons[0], buttons[1], 'exclusive_group', { groupName: 'tabs' });
app.addRelation(buttons[1], buttons[2], 'exclusive_group', { groupName: 'tabs' });
// Click → pulse event → activate
for (let i = 0; i < 3; i++) {
app.addRelation(buttons[i], events[i], 'on_click_fire');
app.addRelation(events[i], buttons[i], 'on_event_set_active');
}
// Activation transitions → panel visibility
for (let i = 0; i < 3; i++) {
app.addRelation(buttons[i], panels[i], 'on_enter_set_visibility', { visible: true });
app.addRelation(buttons[i], panels[i], 'on_exit_set_visibility', { visible: false });
}
That’s it. The result is a working tablist with screen-reader announcements, full keyboard support, and continuous animations on each panel — all from the same declarative graph.
Cross-references
- Built-in Relations — continuous (orbits, follows, attached_to, etc.)
- Custom Relations — defining your own per-frame behaviors
- Queries — inspecting the relation graph at runtime
- MCP Tools — agent-facing surface
- Blog: One Declarative Graph, Four Outputs — architectural background