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]. Pass source: 'item' to read top-level item properties (item.opacity, item.position.x via dotted keys is not supported — flatten into data first).
  • Missing keys, null, and undefined resolve to an empty string for stable output. 0 and false are preserved as '0' and 'false'.
  • Registration order matters when chaining: if you increment first and then set_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:

  1. pulseEvent(id) pushes a { eventId, payload } entry onto an internal queue.
  2. The frame loop calls _drainEventQueue once per frame, before per-frame callbacks.
  3. 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_fireon_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