diff --git a/CLAUDE.md b/CLAUDE.md
index 572c2c3..a21ee7f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,6 +52,7 @@ title-bar controls). The rules that matter most:
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
+ `node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Tap API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`; `--read-only` fails every switch).
## Reference code (read-only forks — don't develop these here)
diff --git a/README.md b/README.md
index 2fe2a0f..47e18b1 100644
--- a/README.md
+++ b/README.md
@@ -510,6 +510,26 @@ below. Nothing is installed without someone clicking.
read from the account), and a folded **Timing** group for the poll
intervals and a position trim.
Tile settings: cue name, previous cue, playhead, cue list.
+- **Overflow Tap** — what Overflow's own ProPresenter helper app does,
+ inside ProdDash: the live slide's notes are read from ProPresenter, and
+ when they carry one of the admin page's keywords, the chosen Overflow Tap
+ groups are pointed at that keyword's destination — so a tap on a disc
+ follows what is on screen. A slide with no keyword changes nothing. The
+ tile is one keyword, big: what Tap is on (and since when), its destination
+ and the groups moved, a chip saying whether the slide on screen carries a
+ keyword, and the last four switches with their times; a failed switch
+ turns the tile red with the reason and, if switched on in admin, flashes
+ an alert channel. Admin config: the ProPresenter endpoint, the Overflow
+ API credentials (Developers › API Keys in the Overflow Dashboard), the Tap
+ groups to switch (read from the account), the keywords — one per line as
+ `give = Give Online`, optionally `@ Lobby, Kids Wing` to switch only
+ those groups — and how a keyword matches (whole word, anywhere, or on its
+ own line). Tile settings are four switches: destination, groups, slide
+ match, history. **One thing is still unverified:** Overflow's public API
+ documents only *reads* for Tap, so the call that moves a group (`PATCH
+ /api/v3/tap/groups/{id}` with the new `destinationId`) is the natural
+ shape, not a documented one; every switch is confirmed by reading the
+ group back, so a refusal shows on the tile rather than passing silently.
- **Band Lineup** — the backstage stage board: everyone on stage in their mic
slot with photo, name and mic/pack label, on one shared board that every
screen sees the same way — whoever edits it changes it everywhere, over SSE.
@@ -560,8 +580,12 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
board — a plan, its team members, birthdays and photos — run ProdDash with
-`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
-the [module guide](docs/MODULE-GUIDE.md).
+`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
+`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry
+keywords on `127.0.0.1:1602` plus the Overflow Tap API on `127.0.0.1:24720`
+— run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`;
+`--read-only` makes the switch fail the way the public API does today). See
+the testing section of the [module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app
diff --git a/modules/overflow-tap/client.js b/modules/overflow-tap/client.js
new file mode 100644
index 0000000..29f3a46
--- /dev/null
+++ b/modules/overflow-tap/client.js
@@ -0,0 +1,231 @@
+/* Overflow Tap — the tile.
+ *
+ * One fact matters across a booth: which keyword Tap is on right now, and
+ * whether the switch actually landed. That is the big line. Under it, the
+ * destination it maps to and the groups it moved; a small line says whether
+ * the slide on screen carries a keyword at all; and the last four switches
+ * sit at the bottom with their times, so a tech can see the service's trail.
+ *
+ * Everything comes from the server's `state` stream. The tile never talks to
+ * ProPresenter or Overflow itself.
+ */
+
+const HISTORY_SHOWN = 4;
+
+const ICON_REFRESH = '';
+
+export default function create({ root, moduleApi }) {
+ let state = null;
+ let stream = null;
+ let refreshBtn = null;
+ let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state
+
+ const settings = () => moduleApi.instanceSettings || {};
+ const on = (key) => settings()[key] !== false;
+
+ function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined && text !== null) node.textContent = text;
+ return node;
+ }
+
+ function clock(ms) {
+ if (!ms) return '';
+ try {
+ return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ } catch {
+ return '';
+ }
+ }
+
+ /** The one-line reason the tile cannot do its job, or '' when it can. */
+ function blocker() {
+ if (!state) return 'Waiting for ProdDash';
+ const pp = state.propresenter || {};
+ const of = state.overflow || {};
+ if (!pp.configured) return 'No ProPresenter host — set one in /admin';
+ if (!of.configured) return 'No Overflow API key — add it in /admin';
+ if (!(state.keywords || []).length) return 'No keywords — add them in /admin';
+ return '';
+ }
+
+ function render() {
+ root.textContent = '';
+ const box = el('div', 'ot-root');
+ root.append(box);
+
+ const stop = blocker();
+ if (stop) {
+ box.append(el('div', 'ot-empty', stop));
+ return;
+ }
+
+ const pp = state.propresenter || {};
+ const of = state.overflow || {};
+ const cur = state.current;
+ const offline = streamDown || !pp.reachable || !of.reachable;
+
+ if (offline) {
+ box.classList.add('is-offline');
+ const why = streamDown ? 'ProdDash connection lost'
+ : !of.reachable ? (of.error || 'Overflow unreachable')
+ : (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable');
+ box.append(el('div', 'ot-offline', why));
+ }
+
+ const main = el('div', 'ot-main');
+ if (cur) {
+ // The label carries the state and, when there is one, the moment it
+ // became true; the small lines under the destination are the groups
+ // moved — or, when the switch failed or was undone in Overflow, what
+ // went wrong, in their place (a tile has room for one or the other).
+ let label = `TAP IS ON · since ${clock(cur.at)}`;
+ let tone = 'ok';
+ let note = '';
+ let noteClass = 'ot-error';
+ if (cur.pending) {
+ label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING';
+ tone = 'muted';
+ } else if (!cur.ok) {
+ label = `NOT SWITCHED · ${clock(cur.at)}`;
+ tone = 'alert';
+ note = cur.error || '';
+ } else if (cur.drift) {
+ label = `TAP WAS ON · since ${clock(cur.at)}`;
+ tone = 'warn';
+ note = cur.drift;
+ noteClass = 'ot-error is-warn';
+ }
+ main.dataset.tone = tone;
+ main.append(el('div', 'ot-label', label));
+ main.append(el('div', 'ot-keyword', cur.keyword));
+ if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination ? cur.destination.name : ''));
+ if (note) main.append(el('div', noteClass, note));
+ else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · ')));
+ } else {
+ main.dataset.tone = 'muted';
+ main.append(el('div', 'ot-label', 'NO KEYWORD YET'));
+ main.append(el('div', 'ot-keyword is-none', '—'));
+ main.append(el('div', 'ot-dest', 'Tap stays where Overflow left it'));
+ }
+ box.append(main);
+
+ if (on('showSlide')) {
+ const slide = state.slide || {};
+ let text;
+ let cls = 'ot-slide';
+ if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
+ else if (!slide.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
+ else if (slide.unmapped) { text = `This slide: ${slide.keyword} — ${slide.unmapped}`; cls += ' is-warn'; }
+ else if (slide.keyword) { text = `This slide: ${slide.keyword}`; cls += ' is-hit'; }
+ else { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
+ box.append(el('div', cls, text));
+ }
+
+ if (on('showHistory')) {
+ const rows = (state.history || []).slice(0, HISTORY_SHOWN);
+ const list = el('ol', 'ot-history');
+ if (!rows.length) {
+ list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet'));
+ }
+ for (const h of rows) {
+ const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`);
+ row.title = h.ok
+ ? `${h.keyword} → ${h.destination ? h.destination.name : ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}`
+ : `${h.keyword}: ${h.error || 'failed'}`;
+ row.append(el('span', 'ot-h-time', clock(h.at)));
+ row.append(el('span', 'ot-h-kw', h.keyword));
+ row.append(el('span', 'ot-h-dest', h.destination ? h.destination.name : ''));
+ row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕'));
+ list.append(row);
+ }
+ box.append(list);
+ }
+ }
+
+ function applyStatus() {
+ if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash');
+ const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting';
+ moduleApi.setStatus(s, state.message || '');
+ }
+
+ function handleState(e) {
+ try {
+ state = JSON.parse(e.data);
+ } catch {
+ return;
+ }
+ streamDown = false;
+ applyStatus();
+ render();
+ }
+
+ async function backfill() {
+ try {
+ const res = await moduleApi.fetch('/state');
+ if (!res.ok) return;
+ const body = await res.json();
+ if (!body || !body.state || !stream) return;
+ state = body.state;
+ streamDown = false;
+ applyStatus();
+ render();
+ } catch {
+ /* the stream will bring the next state */
+ }
+ }
+
+ async function refresh() {
+ if (!refreshBtn) return;
+ refreshBtn.classList.add('active');
+ try {
+ const res = await moduleApi.fetch('/refresh', { method: 'POST' });
+ if (!res.ok) {
+ let why = `Overflow refresh failed (${res.status})`;
+ try { why = (await res.json()).error || why; } catch { /* keep the status */ }
+ moduleApi.setStatus('error', why);
+ }
+ } catch (err) {
+ moduleApi.setStatus('error', `Overflow refresh failed: ${err && err.message ? err.message : err}`);
+ } finally {
+ refreshBtn.classList.remove('active');
+ }
+ }
+
+ return {
+ start() {
+ moduleApi.setStatus('connecting', 'Connecting');
+ refreshBtn = moduleApi.header.addButton({
+ icon: ICON_REFRESH,
+ title: 'Reload Overflow',
+ onClick: refresh,
+ });
+ stream = moduleApi.sse('/stream', {
+ // A fresh connection replays the state; a tile joining a stream the
+ // page already carries gets only `open`, and the server sends state
+ // on change alone — so backfill, or a second tile waits for the next
+ // switch to draw anything.
+ open() { backfill(); },
+ error() {
+ moduleApi.setStatus('error', 'Lost the connection to ProdDash');
+ streamDown = true;
+ render();
+ },
+ events: { state: handleState },
+ });
+ render();
+ },
+
+ stop() {
+ if (stream) stream.close();
+ stream = null;
+ refreshBtn = null;
+ root.textContent = '';
+ },
+
+ onConfigChange() {
+ render();
+ },
+ };
+}
diff --git a/modules/overflow-tap/keywords.js b/modules/overflow-tap/keywords.js
new file mode 100644
index 0000000..6727b9a
--- /dev/null
+++ b/modules/overflow-tap/keywords.js
@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * Keyword mappings — reading the admin page's Keywords text and deciding
+ * which keyword a slide's notes carry.
+ *
+ * Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
+ *
+ * parseKeywordLines(text) → [{ line, keyword, destination, groups, problem }]
+ * keywordMatches(notes, keyword, mode) → boolean
+ * matchKeyword(notes, mappings, mode) → the first mapping whose keyword the notes carry, or null
+ * cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
+ *
+ * The Keywords text is one mapping per line:
+ *
+ * give = Give Online
+ * connect -> Connect Card @ Lobby, Kids Wing
+ * # a comment
+ *
+ * `=`, `->` and `→` all separate the keyword from the destination. A trailing
+ * ` @ ` names the Tap groups this line switches (by name or id); without it the
+ * admin page's chosen groups apply. The destination is the name Overflow shows
+ * for it, or its id — resolving that against the account is the server's job,
+ * so a parse never needs the network.
+ */
+
+const ZERO_WIDTH = /[-]/g;
+
+function cleanNotes(raw) {
+ return String(raw ?? '').replace(ZERO_WIDTH, '').replace(/\r\n?/g, '\n');
+}
+
+function norm(s) {
+ return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+function parseKeywordLines(text) {
+ const out = [];
+ const seen = new Set();
+ cleanNotes(text).split('\n').forEach((raw, i) => {
+ const line = raw.trim();
+ if (!line || line.startsWith('#')) return;
+ const m = /^(.*?)\s*(?:=|->|→)\s*(.*)$/.exec(line);
+ const entry = { line: i + 1, keyword: '', destination: '', groups: [], problem: '' };
+ if (!m) {
+ entry.keyword = line;
+ entry.problem = 'No destination — write it as "keyword = Destination name"';
+ out.push(entry);
+ return;
+ }
+ entry.keyword = m[1].trim();
+ let rest = m[2].trim();
+ const at = rest.indexOf('@');
+ if (at >= 0) {
+ entry.groups = rest.slice(at + 1).split(',').map((s) => s.trim()).filter(Boolean);
+ rest = rest.slice(0, at).trim();
+ }
+ entry.destination = rest;
+ if (!entry.keyword) entry.problem = 'No keyword before the =';
+ else if (!entry.destination) entry.problem = 'No destination after the =';
+ else if (seen.has(norm(entry.keyword))) entry.problem = 'Repeated keyword — the first line wins';
+ if (entry.keyword) seen.add(norm(entry.keyword));
+ out.push(entry);
+ });
+ return out;
+}
+
+function escapeRe(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Does this slide's notes carry the keyword?
+ * word — the keyword (a word or a phrase) bounded by the start/end of the
+ * notes or by something that is not a letter, digit or underscore.
+ * `give` fires on "give" and "give!" but not on "forgiveness".
+ * anywhere — plain substring, what Overflow's own helper does.
+ * line — a line of the notes is exactly the keyword.
+ * All three ignore case and collapse runs of whitespace inside a phrase.
+ */
+function keywordMatches(notes, keyword, mode = 'word') {
+ const n = cleanNotes(notes);
+ const k = norm(keyword);
+ if (!k || !n.trim()) return false;
+ if (mode === 'line') return n.split('\n').some((l) => norm(l) === k);
+ const flat = n.toLowerCase().replace(/\s+/g, ' ');
+ if (mode === 'anywhere') return flat.includes(k);
+ const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
+ return re.test(flat);
+}
+
+/** The first mapping (in admin order) whose keyword the notes carry, or null. */
+function matchKeyword(notes, mappings, mode = 'word') {
+ for (const m of mappings || []) {
+ if (!m || !m.keyword) continue;
+ if (keywordMatches(notes, m.keyword, mode)) return m;
+ }
+ return null;
+}
+
+module.exports = { cleanNotes, norm, parseKeywordLines, keywordMatches, matchKeyword };
diff --git a/modules/overflow-tap/keywords.selftest.js b/modules/overflow-tap/keywords.selftest.js
new file mode 100644
index 0000000..049dde8
--- /dev/null
+++ b/modules/overflow-tap/keywords.selftest.js
@@ -0,0 +1,115 @@
+'use strict';
+
+/**
+ * Self-test for keywords.js — the parsing and matching rules, pinned.
+ *
+ * node modules/overflow-tap/keywords.selftest.js
+ */
+
+const assert = require('node:assert/strict');
+const { parseKeywordLines, keywordMatches, matchKeyword, cleanNotes } = require('./keywords');
+
+let n = 0;
+function test(name, fn) {
+ n += 1;
+ try {
+ fn();
+ console.log(`ok ${name}`);
+ } catch (err) {
+ console.log(`FAIL ${name}\n ${err.message}`);
+ process.exitCode = 1;
+ }
+}
+
+test('parses keyword = destination, with -> and → too', () => {
+ const m = parseKeywordLines('give = Give Online\nconnect -> Connect Card\nprayer → Prayer Request');
+ assert.deepEqual(m.map((x) => [x.keyword, x.destination, x.problem]), [
+ ['give', 'Give Online', ''],
+ ['connect', 'Connect Card', ''],
+ ['prayer', 'Prayer Request', ''],
+ ]);
+});
+
+test('a trailing @ names the groups for that line', () => {
+ const [m] = parseKeywordLines('connect = Connect Card @ Lobby, Kids Wing');
+ assert.equal(m.destination, 'Connect Card');
+ assert.deepEqual(m.groups, ['Lobby', 'Kids Wing']);
+});
+
+test('blank lines and # comments are skipped, line numbers kept', () => {
+ const m = parseKeywordLines('\n# giving\n\ngive = Give Online\n');
+ assert.equal(m.length, 1);
+ assert.equal(m[0].line, 4);
+});
+
+test('a line without a separator is a problem, not a crash', () => {
+ const [m] = parseKeywordLines('give');
+ assert.equal(m.keyword, 'give');
+ assert.match(m.problem, /No destination/);
+});
+
+test('missing keyword or destination is reported', () => {
+ const [a, b] = parseKeywordLines('= Give Online\ngive =');
+ assert.match(a.problem, /No keyword/);
+ assert.match(b.problem, /No destination/);
+});
+
+test('a repeated keyword is flagged and the first line wins', () => {
+ const m = parseKeywordLines('give = A\nGIVE = B');
+ assert.equal(m[0].problem, '');
+ assert.match(m[1].problem, /Repeated/);
+ assert.equal(matchKeyword('give', m).destination, 'A');
+});
+
+test('keywords and destinations may be phrases', () => {
+ const [m] = parseKeywordLines('next steps = Next Steps Card');
+ assert.equal(m.keyword, 'next steps');
+ assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps', 'word'), true);
+});
+
+test('whole word: give does not fire on forgiveness, does on "give!"', () => {
+ assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'word'), false);
+ assert.equal(keywordMatches('give!', 'give', 'word'), true);
+ assert.equal(keywordMatches('Give', 'give', 'word'), true);
+ assert.equal(keywordMatches('#give now', 'give', 'word'), true);
+ assert.equal(keywordMatches('giveaway', 'give', 'word'), false);
+ assert.equal(keywordMatches('give_now', 'give', 'word'), false);
+});
+
+test('anywhere: plain substring, like the Overflow helper', () => {
+ assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'anywhere'), true);
+ assert.equal(keywordMatches('nothing here', 'give', 'anywhere'), false);
+});
+
+test('line: the keyword on its own line only', () => {
+ assert.equal(keywordMatches('speaker note\ngive\nmore', 'give', 'line'), true);
+ assert.equal(keywordMatches('give now', 'give', 'line'), false);
+ assert.equal(keywordMatches(' Give ', 'give', 'line'), true);
+});
+
+test('regex characters in a keyword are literal', () => {
+ assert.equal(keywordMatches('go to (give) now', '(give)', 'word'), true);
+ assert.equal(keywordMatches('give.now', 'give.now', 'anywhere'), true);
+ assert.equal(keywordMatches('giveXnow', 'give.now', 'anywhere'), false);
+});
+
+test('empty notes never match; zero-width padding is ignored', () => {
+ assert.equal(keywordMatches('', 'give'), false);
+ assert.equal(keywordMatches('', 'give', 'anywhere'), false);
+ assert.equal(cleanNotes('give'), 'give');
+ assert.equal(keywordMatches('give', 'give', 'line'), true);
+});
+
+test('matchKeyword returns the first mapping in admin order', () => {
+ const m = parseKeywordLines('connect = Connect Card\ngive = Give Online');
+ assert.equal(matchKeyword('give and connect', m).keyword, 'connect');
+ assert.equal(matchKeyword('just worship', m), null);
+ assert.equal(matchKeyword('give', []), null);
+});
+
+test('a mapping with a parse problem can still be the match (the caller decides)', () => {
+ const m = parseKeywordLines('baptism');
+ assert.equal(matchKeyword('baptism', m).problem.length > 0, true);
+});
+
+console.log(`${n} tests, exit ${process.exitCode || 0}`);
diff --git a/modules/overflow-tap/module.json b/modules/overflow-tap/module.json
new file mode 100644
index 0000000..4fec966
--- /dev/null
+++ b/modules/overflow-tap/module.json
@@ -0,0 +1,144 @@
+{
+ "id": "overflow-tap",
+ "name": "Overflow Tap",
+ "version": "1.0.0",
+ "proddash": ">=1.11.0",
+ "description": "Switches Overflow Tap destinations from keywords in ProPresenter slide notes",
+ "permissions": [
+ {
+ "kind": "local-network",
+ "reason": "Reaching ProPresenter on this network"
+ }
+ ],
+ "client": "client.js",
+ "server": "server.js",
+ "style": "style.css",
+ "minSize": {
+ "w": 2,
+ "h": 2
+ },
+ "defaultSize": {
+ "w": 4,
+ "h": 3
+ },
+ "configGroups": {
+ "ProPresenter": {
+ "help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes are read — nothing on the stage screens changes."
+ },
+ "Overflow": {
+ "collapsed": "whenSet",
+ "help": "API credentials from the Overflow Dashboard: Developers › API Keys (a READ_WRITE key). Tap groups and destinations are read from the account once these are saved; the groups list below fills in from there."
+ },
+ "Keywords": {
+ "help": "One keyword per line, as `keyword = Destination name` — the destination exactly as it is named in Overflow's Tap module (or its id). Add ` @ Group A, Group B` to a line to switch only those groups instead of the ones chosen above. When a slide's notes carry one of these keywords, every chosen group is pointed at that destination; a slide without a keyword changes nothing. When several match, the first line wins."
+ },
+ "Alerts": {
+ "toggle": "alertEnabled",
+ "collapsed": true,
+ "help": "Flash the dashboards listening on this channel when a switch fails — Overflow unreachable or refusing the change — so someone can fix Tap by hand before the moment passes."
+ }
+ },
+ "configSchema": {
+ "propresenter": {
+ "type": "endpoint",
+ "label": "ProPresenter",
+ "group": "ProPresenter",
+ "default": {
+ "host": "",
+ "port": 1025
+ }
+ },
+ "password": {
+ "type": "password",
+ "label": "API password",
+ "group": "ProPresenter",
+ "default": "",
+ "help": "Only if a password is set in ProPresenter's network preferences."
+ },
+ "clientId": {
+ "type": "string",
+ "label": "Client ID",
+ "group": "Overflow",
+ "default": ""
+ },
+ "apiKey": {
+ "type": "password",
+ "label": "API key",
+ "group": "Overflow",
+ "default": ""
+ },
+ "groups": {
+ "type": "multiselect",
+ "label": "Tap groups",
+ "group": "Overflow",
+ "default": [],
+ "optionsRoute": "/groups",
+ "help": "The groups a keyword switches. None chosen means every group on the account."
+ },
+ "keywords": {
+ "type": "text",
+ "label": "Keywords",
+ "group": "Keywords",
+ "default": "",
+ "help": "One per line — give = Give Online"
+ },
+ "matching": {
+ "type": "select",
+ "label": "Match",
+ "group": "Keywords",
+ "default": "word",
+ "options": [
+ {
+ "value": "word",
+ "label": "Whole word in the notes"
+ },
+ {
+ "value": "anywhere",
+ "label": "Anywhere in the notes"
+ },
+ {
+ "value": "line",
+ "label": "On its own line"
+ }
+ ],
+ "help": "Whole word keeps `give` from firing on `forgiveness`; anywhere is what Overflow's own helper does."
+ },
+ "alertEnabled": {
+ "type": "switch",
+ "label": "Alert when a switch fails",
+ "group": "Alerts",
+ "default": false
+ },
+ "alertChannel": {
+ "type": "number",
+ "label": "Channel",
+ "group": "Alerts",
+ "default": 1,
+ "min": 1,
+ "max": 6,
+ "help": "1–6; each dashboard chooses the channels it listens to in its Layouts menu."
+ }
+ },
+ "instanceSchema": {
+ "showDestination": {
+ "type": "switch",
+ "label": "Destination",
+ "default": true
+ },
+ "showGroups": {
+ "type": "switch",
+ "label": "Groups",
+ "default": true
+ },
+ "showSlide": {
+ "type": "switch",
+ "label": "Slide match",
+ "default": true
+ },
+ "showHistory": {
+ "type": "switch",
+ "label": "History",
+ "default": true
+ }
+ }
+}
diff --git a/modules/overflow-tap/overflow.js b/modules/overflow-tap/overflow.js
new file mode 100644
index 0000000..346df7c
--- /dev/null
+++ b/modules/overflow-tap/overflow.js
@@ -0,0 +1,126 @@
+'use strict';
+
+/**
+ * Overflow API client — the Tap part of Overflow's public REST API.
+ *
+ * https://docs.overflow.co/api-reference (OpenAPI: server.stage.overflow.co/api/docs/openapi.json)
+ *
+ * Authentication is two headers, `x-client-id` and `x-api-key`, from the
+ * Overflow Dashboard's Developers › API Keys. 120 requests per minute per
+ * client; a 429 is answered when the window is spent.
+ *
+ * Reads used here are documented: GET /api/v3/tap/destinations,
+ * GET /api/v3/tap/groups and GET /api/v3/tap/groups/{id} (a group carries the
+ * `destinationId` it currently points at).
+ *
+ * THE SWITCH IS NOT (as of 2026-10-07). The public reference lists reads only
+ * for Tap; Overflow's own ProPresenter helper changes a group's destination
+ * through the Dashboard's private API after signing in with a Dashboard
+ * login. `setGroupDestination` below sends the natural REST shape for this
+ * API — PATCH the group with the new `destinationId`, as PATCH /donors/{id}
+ * updates a donor — and the server confirms every switch by reading the group
+ * back, so an answer that changes nothing is reported, not believed. If
+ * Overflow publishes (or confirms) a different route, this one function is
+ * the only place to change. tools/overflow-tap-mock.js serves the same shape.
+ *
+ * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at that mock.
+ */
+
+const DEFAULT_API_BASE = 'https://server.overflow.co';
+const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
+const TIMEOUT_MS = 10000;
+const PAGE_SIZE = 100;
+const MAX_PAGES = 20;
+const ID_RE = /^[0-9a-f]{24}$/i;
+
+class OverflowError extends Error {
+ constructor(message, { status = 0, retryable = false } = {}) {
+ super(message);
+ this.name = 'OverflowError';
+ this.status = status;
+ this.retryable = retryable;
+ }
+}
+
+function looksLikeId(s) {
+ return ID_RE.test(String(s || '').trim());
+}
+
+function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }) {
+ const id = String(clientId || '').trim();
+ const key = String(apiKey || '').trim();
+
+ async function request(method, path, { query, body } = {}) {
+ if (!id || !key) throw new OverflowError('Overflow client ID and API key are not set', { status: 0 });
+ const url = new URL(API_BASE + path);
+ for (const [k, v] of Object.entries(query || {})) {
+ if (v === undefined || v === null || v === '') continue;
+ url.searchParams.set(k, String(v));
+ }
+ const headers = { Accept: 'application/json', 'x-client-id': id, 'x-api-key': key };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ const ctl = new AbortController();
+ const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
+ let res;
+ try {
+ res = await fetchImpl(url, {
+ method,
+ headers,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: ctl.signal,
+ });
+ } catch (err) {
+ const aborted = err && err.name === 'AbortError';
+ const cause = err && err.cause && (err.cause.code || err.cause.message);
+ throw new OverflowError(aborted ? `Overflow did not answer within ${TIMEOUT_MS / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`, { retryable: true });
+ } finally {
+ clearTimeout(timer);
+ }
+ const text = await res.text();
+ let data = null;
+ if (text) {
+ try { data = JSON.parse(text); } catch { data = null; }
+ }
+ if (!res.ok) {
+ const detail = data && (data.message || data.error || data.detail);
+ const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : res.statusText);
+ let message;
+ if (res.status === 401 || res.status === 403) message = `Overflow refused the credentials (${res.status}${what ? `: ${what}` : ''})`;
+ else if (res.status === 404 || res.status === 405) message = `Overflow has no ${method} ${path} (${res.status})`;
+ else if (res.status === 429) message = 'Overflow is rate-limiting (120 requests a minute)';
+ else message = `Overflow answered ${res.status}${what ? `: ${what}` : ''}`;
+ throw new OverflowError(message, { status: res.status, retryable: res.status === 429 || res.status >= 500 });
+ }
+ return data;
+ }
+
+ /** Every page of a collection, newest-first as the API sorts it. */
+ async function listAll(path, query = {}) {
+ const all = [];
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
+ const res = await request('GET', path, { query: { ...query, limit: PAGE_SIZE, page } });
+ const data = Array.isArray(res && res.data) ? res.data : [];
+ all.push(...data);
+ const total = Number(res && res.totalCount);
+ if (data.length < PAGE_SIZE || (Number.isFinite(total) && all.length >= total)) break;
+ }
+ return all;
+ }
+
+ return {
+ base: API_BASE,
+ configured: Boolean(id && key),
+ listDestinations: () => listAll('/api/v3/tap/destinations'),
+ listGroups: () => listAll('/api/v3/tap/groups'),
+ async getGroup(groupId) {
+ const res = await request('GET', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`);
+ return res && res.data ? res.data : res;
+ },
+ /** See the note at the top of this file: the one unverified call. */
+ setGroupDestination(groupId, destinationId) {
+ return request('PATCH', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`, { body: { destinationId } });
+ },
+ };
+}
+
+module.exports = { createOverflowClient, OverflowError, looksLikeId, API_BASE, DEFAULT_API_BASE };
diff --git a/modules/overflow-tap/server.js b/modules/overflow-tap/server.js
new file mode 100644
index 0000000..149f3ff
--- /dev/null
+++ b/modules/overflow-tap/server.js
@@ -0,0 +1,552 @@
+'use strict';
+
+/**
+ * Overflow Tap — server entry.
+ *
+ * Does what Overflow's "ProPresenter Integration" helper app does, inside
+ * ProdDash: watches the live slide in ProPresenter, and when its notes carry
+ * one of the admin page's keywords, points the chosen Overflow Tap groups at
+ * that keyword's destination — so what people reach when they tap a disc
+ * follows what is on screen. A slide with no keyword changes nothing; the
+ * last destination stays until the next keyworded slide.
+ *
+ * GET /state → the view-model every tile draws
+ * GET /stream → SSE, `state` events on every change (heartbeated)
+ * POST /refresh → re-read Overflow's groups and destinations now
+ * GET /groups → optionsRoute for the admin page's Tap groups picker
+ * GET /destinations → the destinations, for a quick look while setting up
+ *
+ * ProPresenter is polled (GET /v1/status/slide, twice a second — one small
+ * request, nothing heavier) because its chunked update stream is the thing
+ * that goes quiet without saying so. Overflow is read on start, every few
+ * minutes after that, and on /refresh; every switch is confirmed by reading
+ * the group back. Which Overflow call does the switching, and why that call
+ * is the one unverified piece, is explained at the top of overflow.js.
+ *
+ * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at
+ * tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602.
+ */
+
+const { createOverflowClient, OverflowError, looksLikeId } = require('./overflow');
+const { parseKeywordLines, matchKeyword, cleanNotes, norm } = require('./keywords');
+
+const POLL_MS = 500; // ProPresenter, while it answers
+const RETRY_MS = 3000; // ProPresenter, while it does not
+const PP_TIMEOUT_MS = 4000;
+const HEARTBEAT_MS = 15000;
+const DESTINATIONS_REFRESH_MS = 5 * 60 * 1000;
+const GROUPS_REFRESH_MS = 60 * 1000; // also how often drift (a change made in Overflow by hand) is noticed
+const OVERFLOW_RETRY_MS = 30 * 1000;
+const SWITCH_RETRY_MS = 10 * 1000;
+const SWITCH_RETRIES = 3;
+const HISTORY_MAX = 8; // the tile shows four; a few more for the hover
+const ALERT_SECONDS = 30;
+const MATCH_MODES = new Set(['word', 'anywhere', 'line']);
+
+/** Set by init(), read by routes() — both are rebuilt together on a config save. */
+let current = null;
+
+function json(res, code, body) {
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(body));
+}
+
+/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */
+function describe(err) {
+ if (err instanceof OverflowError) return err.message;
+ if (err && err.name === 'AbortError') return 'timed out';
+ const msg = err && err.message ? err.message : String(err);
+ const cause = err && err.cause && (err.cause.code || err.cause.message);
+ return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg;
+}
+
+module.exports = {
+ init({ config, log, shell }) {
+ /* ── config ────────────────────────────────────────────────────── */
+ const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
+ const host = String(ep.host || '').trim();
+ const port = Number(ep.port) || 1025;
+ const password = String(config.password || '');
+ const chosenGroups = (Array.isArray(config.groups) ? config.groups : []).map((g) => String(g || '').trim()).filter(Boolean);
+ const mappings = parseKeywordLines(config.keywords);
+ const mode = MATCH_MODES.has(config.matching) ? config.matching : 'word';
+ const alertEnabled = Boolean(config.alertEnabled);
+ const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
+
+ const overflow = createOverflowClient({ clientId: config.clientId, apiKey: config.apiKey });
+
+ /* ── state ─────────────────────────────────────────────────────── */
+ const streams = new Set();
+ const timers = new Set();
+ let stopped = false;
+ let lastFrame = '';
+
+ let destinations = []; // Overflow's, as listed
+ let groups = []; // Overflow's, as listed (each with its current destinationId)
+ let resolved = []; // the mappings, resolved against those two lists
+ let lastSlideKey = null; // change detection on the live slide
+ let wanted = null; // the resolved keyword Tap should be on
+ let inFlight = false;
+ let retriesLeft = 0;
+ let retryTimer = null;
+ let alertRaised = false;
+
+ const state = {
+ status: 'connecting',
+ message: '',
+ propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
+ overflow: { configured: overflow.configured, reachable: false, error: '', groups: 0, destinations: 0, loadedAt: 0 },
+ keywords: [],
+ slide: { live: false, keyword: '', unmapped: '', hasNotes: false },
+ current: null,
+ history: [],
+ updatedAt: 0,
+ };
+
+ /* ── timers that die with the handle ───────────────────────────── */
+
+ function later(fn, ms) {
+ const t = setTimeout(() => {
+ timers.delete(t);
+ if (!stopped) fn();
+ }, ms);
+ t.unref?.();
+ timers.add(t);
+ return t;
+ }
+
+ /* ── health, shared by the admin line and the tiles ────────────── */
+
+ function assess() {
+ const pp = state.propresenter;
+ const of = state.overflow;
+ if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin'];
+ if (!of.configured) return ['error', 'Overflow client ID and API key not set — add them in /admin'];
+ if (!mappings.length) return ['error', 'No keywords configured — add some in /admin'];
+ if (!of.reachable) return ['error', of.error || 'Reaching Overflow…'];
+ if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
+ const bad = state.keywords.filter((k) => k.problem);
+ if (bad.length && bad.length === state.keywords.length) {
+ return ['error', `No keyword maps to a destination — ${bad[0].keyword}: ${bad[0].problem}`];
+ }
+ if (state.current && !state.current.pending && !state.current.ok) {
+ return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`];
+ }
+ if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`];
+ const extra = bad.length ? ` · ${bad.length} keyword${bad.length === 1 ? '' : 's'} without a destination: ${bad.map((k) => k.keyword).join(', ')}` : '';
+ const drift = state.current && state.current.drift ? ` · ${state.current.drift}` : '';
+ return ['ok', `Following ${host}:${port} · Overflow: ${of.groups} group${of.groups === 1 ? '' : 's'}, ${of.destinations} destination${of.destinations === 1 ? '' : 's'}, ${state.keywords.length} keyword${state.keywords.length === 1 ? '' : 's'}${extra}${drift}`];
+ }
+
+ function publish() {
+ if (stopped) return;
+ [state.status, state.message] = assess();
+ const { updatedAt, ...rest } = state;
+ let frame;
+ try {
+ frame = JSON.stringify(rest);
+ } catch {
+ return;
+ }
+ if (frame === lastFrame) return;
+ lastFrame = frame;
+ state.updatedAt = Date.now();
+ const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`;
+ for (const res of streams) {
+ try {
+ res.write(payload);
+ } catch {
+ streams.delete(res);
+ }
+ }
+ }
+
+ /* ── Overflow: destinations, groups, and the keywords resolved ─── */
+
+ function resolveKeywords() {
+ const dById = new Map(destinations.map((d) => [String(d.id), d]));
+ const dByName = new Map(destinations.map((d) => [norm(d.name), d]));
+ const gById = new Map(groups.map((g) => [String(g.id), g]));
+ const gByName = new Map(groups.map((g) => [norm(g.name), g]));
+ const defaults = chosenGroups.length
+ ? chosenGroups.map((id) => gById.get(id)).filter(Boolean)
+ : groups.slice();
+ resolved = mappings.map((m) => {
+ const out = { keyword: m.keyword, destination: m.destination, destinationId: '', groups: [], problem: m.problem };
+ if (out.problem) return out;
+ const d = (looksLikeId(m.destination) && dById.get(m.destination.trim())) || dByName.get(norm(m.destination));
+ if (!d) {
+ out.problem = destinations.length
+ ? `No Overflow destination named "${m.destination}"`
+ : 'Overflow destinations not read yet';
+ return out;
+ }
+ out.destination = String(d.name || m.destination);
+ out.destinationId = String(d.id);
+ let gs;
+ if (m.groups.length) {
+ gs = [];
+ const missing = [];
+ for (const name of m.groups) {
+ const g = (looksLikeId(name) && gById.get(name)) || gByName.get(norm(name));
+ if (g) gs.push(g);
+ else missing.push(name);
+ }
+ if (missing.length) {
+ out.problem = `No Tap group named ${missing.map((x) => `"${x}"`).join(', ')}`;
+ return out;
+ }
+ } else {
+ gs = defaults;
+ }
+ if (!gs.length) {
+ out.problem = groups.length
+ ? 'None of the chosen Tap groups exist any more'
+ : 'No Tap groups on this Overflow account';
+ return out;
+ }
+ out.groups = gs.map((g) => ({ id: String(g.id), name: String(g.name || g.id) }));
+ return out;
+ });
+ state.keywords = resolved.map((r) => ({
+ keyword: r.keyword,
+ destination: r.destination,
+ groups: r.groups.map((g) => g.name),
+ problem: r.problem,
+ }));
+ }
+
+ /** Has anyone moved a group we switched? Noted on the live entry, never fought. */
+ function noteDrift() {
+ const cur = state.current;
+ if (!cur || !cur.ok || cur.pending) return;
+ const byId = new Map(groups.map((g) => [String(g.id), g]));
+ const moved = [];
+ for (const g of cur.groupIds || []) {
+ const now = byId.get(g);
+ if (!now) continue;
+ if (String(now.destinationId || '') !== cur.destination.id) {
+ moved.push(`${now.name} now points at ${now.destination && now.destination.name ? now.destination.name : 'another destination'}`);
+ }
+ }
+ cur.drift = moved.length ? `changed in Overflow: ${moved.join('; ')}` : '';
+ }
+
+ let destinationsTimer = null;
+ let groupsTimer = null;
+ let loading = null;
+
+ async function loadOverflow(what = 'all') {
+ if (stopped || !overflow.configured) return;
+ if (loading) return loading;
+ loading = (async () => {
+ try {
+ if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations();
+ groups = await overflow.listGroups();
+ state.overflow.reachable = true;
+ state.overflow.error = '';
+ state.overflow.groups = groups.length;
+ state.overflow.destinations = destinations.length;
+ state.overflow.loadedAt = Date.now();
+ resolveKeywords();
+ noteDrift();
+ // A slide that was already up when the lists arrived (or when a
+ // destination got its name fixed in Overflow) should take effect now.
+ if (lastSlideKey !== null) reconsiderSlide();
+ } catch (err) {
+ state.overflow.reachable = false;
+ state.overflow.error = describe(err);
+ log(`Overflow: ${state.overflow.error}`);
+ if (!resolved.length) resolveKeywords(); // so the keyword list at least shows the lines
+ }
+ publish();
+ })();
+ try {
+ await loading;
+ } finally {
+ loading = null;
+ }
+ scheduleOverflow();
+ }
+
+ function scheduleOverflow() {
+ if (stopped) return;
+ if (destinationsTimer) clearTimeout(destinationsTimer);
+ if (groupsTimer) clearTimeout(groupsTimer);
+ timers.delete(destinationsTimer);
+ timers.delete(groupsTimer);
+ const ok = state.overflow.reachable;
+ destinationsTimer = later(() => loadOverflow('all'), ok ? DESTINATIONS_REFRESH_MS : OVERFLOW_RETRY_MS);
+ groupsTimer = ok ? later(() => loadOverflow('groups'), GROUPS_REFRESH_MS) : null;
+ }
+
+ /* ── the switch ────────────────────────────────────────────────── */
+
+ function pushHistory(entry) {
+ const { pending, drift, ...rest } = entry;
+ state.history.unshift(rest);
+ if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX;
+ }
+
+ function raiseAlert(keyword) {
+ if (!alertEnabled || !shell || typeof shell.alert !== 'function') return;
+ alertRaised = true;
+ Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS }))
+ .catch((err) => log(`alert refused: ${describe(err)}`));
+ }
+
+ function clearAlert() {
+ if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return;
+ alertRaised = false;
+ Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {});
+ }
+
+ function requestSwitch(target) {
+ wanted = target;
+ retriesLeft = SWITCH_RETRIES;
+ if (retryTimer) {
+ clearTimeout(retryTimer);
+ timers.delete(retryTimer);
+ retryTimer = null;
+ }
+ runSwitch();
+ }
+
+ async function runSwitch() {
+ if (stopped || inFlight || !wanted) return;
+ const target = wanted;
+ const cur = state.current;
+ const sameTarget = cur && cur.keyword === target.keyword && cur.destination.id === target.destinationId;
+ if (sameTarget && cur.ok) return; // already there
+ inFlight = true;
+ let entry;
+ if (sameTarget) {
+ entry = cur; // another go at the same switch
+ } else {
+ if (cur) pushHistory(cur);
+ entry = {
+ keyword: target.keyword,
+ destination: { id: target.destinationId, name: target.destination },
+ groups: target.groups.map((g) => g.name),
+ groupIds: target.groups.map((g) => g.id),
+ at: Date.now(),
+ ok: false,
+ pending: true,
+ error: '',
+ attempts: 0,
+ drift: '',
+ };
+ state.current = entry;
+ }
+ entry.pending = true;
+ entry.attempts += 1;
+ publish();
+ try {
+ for (const g of target.groups) {
+ if (stopped) return;
+ await overflow.setGroupDestination(g.id, target.destinationId);
+ const check = await overflow.getGroup(g.id);
+ const nowAt = check ? String(check.destinationId || '') : '';
+ if (check && nowAt !== target.destinationId) {
+ const name = check.destination && check.destination.name ? `"${check.destination.name}"` : 'another destination';
+ throw new OverflowError(`Overflow accepted the request but "${g.name}" still points at ${name}`, { status: 200 });
+ }
+ const cached = groups.find((x) => String(x.id) === g.id);
+ if (cached) {
+ cached.destinationId = target.destinationId;
+ cached.destination = { id: target.destinationId, name: target.destination };
+ }
+ }
+ entry.ok = true;
+ entry.error = '';
+ entry.at = Date.now();
+ entry.drift = '';
+ clearAlert();
+ log(`Tap → ${target.destination} (${target.keyword}) on ${target.groups.map((g) => g.name).join(', ')}`);
+ } catch (err) {
+ if (stopped) return;
+ entry.ok = false;
+ entry.error = describe(err);
+ log(`Tap not switched to ${target.destination} (${target.keyword}): ${entry.error}`);
+ if (retriesLeft > 0) {
+ retriesLeft -= 1;
+ retryTimer = later(() => {
+ retryTimer = null;
+ runSwitch();
+ }, SWITCH_RETRY_MS);
+ }
+ raiseAlert(target.keyword);
+ } finally {
+ entry.pending = false;
+ inFlight = false;
+ publish();
+ if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile
+ }
+ }
+
+ /* ── ProPresenter: the live slide ──────────────────────────────── */
+
+ function ppUrl(target) {
+ const u = new URL(`http://${host}:${port}${target}`);
+ if (password) u.searchParams.set('password', password);
+ return u;
+ }
+
+ async function ppJson(target) {
+ const ctl = new AbortController();
+ const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS);
+ try {
+ const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal });
+ if (!res.ok) throw new Error(`ProPresenter ${res.status} ${res.statusText}`);
+ const text = await res.text();
+ return text ? JSON.parse(text) : null;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
+ let lastSlide = null;
+
+ function reconsiderSlide() {
+ const cur = lastSlide;
+ if (!cur) {
+ state.slide = { live: false, keyword: '', unmapped: '', hasNotes: false };
+ return;
+ }
+ const notes = cleanNotes(cur.notes);
+ const hit = matchKeyword(notes, resolved, mode);
+ state.slide = {
+ live: true,
+ keyword: hit ? hit.keyword : '',
+ unmapped: hit && hit.problem ? hit.problem : '',
+ hasNotes: Boolean(notes.trim()),
+ };
+ if (hit && !hit.problem) requestSwitch(hit);
+ }
+
+ async function pollSlide() {
+ if (stopped) return;
+ let ok = false;
+ try {
+ const data = await ppJson('/v1/status/slide');
+ const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
+ state.propresenter.reachable = true;
+ state.propresenter.error = '';
+ ok = true;
+ const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
+ if (key !== lastSlideKey) {
+ lastSlideKey = key;
+ lastSlide = cur;
+ reconsiderSlide();
+ }
+ } catch (err) {
+ state.propresenter.reachable = false;
+ state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
+ }
+ publish();
+ later(pollSlide, ok ? POLL_MS : RETRY_MS);
+ }
+
+ /* ── go ────────────────────────────────────────────────────────── */
+
+ resolveKeywords();
+ if (host) {
+ pollSlide();
+ log(`following ProPresenter at ${host}:${port}`);
+ } else {
+ log('no ProPresenter host configured — set one in /admin');
+ }
+ if (overflow.configured) {
+ loadOverflow('all');
+ log(`Overflow at ${overflow.base}`);
+ } else {
+ log('Overflow client ID / API key not set — add them in /admin');
+ }
+ publish();
+
+ const heartbeat = setInterval(() => {
+ for (const res of streams) {
+ try {
+ res.write(': ping\n\n');
+ } catch {
+ streams.delete(res);
+ }
+ }
+ }, HEARTBEAT_MS);
+ heartbeat.unref?.();
+
+ const handle = {
+ streams,
+ getState: () => state,
+ refresh: () => loadOverflow('all'),
+ groupOptions: () => groups.map((g) => ({ value: String(g.id), label: String(g.name || g.id) })),
+ destinationOptions: () => destinations.map((d) => ({ value: String(d.id), label: String(d.name || d.id) })),
+ configured: overflow.configured,
+ };
+ current = handle;
+
+ return {
+ stop() {
+ stopped = true;
+ clearInterval(heartbeat);
+ for (const t of timers) clearTimeout(t);
+ timers.clear();
+ clearAlert();
+ for (const res of streams) {
+ try { res.end(); } catch { /* already gone */ }
+ }
+ streams.clear();
+ if (current === handle) current = null;
+ },
+ health() {
+ const [status, message] = assess();
+ return { status, message };
+ },
+ };
+ },
+
+ routes() {
+ return {
+ 'GET /state': (req, res) => {
+ if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
+ json(res, 200, { state: current.getState() });
+ },
+
+ 'GET /stream': (req, res) => {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ 'Cache-Control': 'no-cache, no-transform',
+ Connection: 'keep-alive',
+ 'X-Accel-Buffering': 'no',
+ });
+ res.write('retry: 3000\n\n');
+ if (!current) return void res.end();
+ res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`);
+ try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
+ current.streams.add(res);
+ req.on('close', () => current && current.streams.delete(res));
+ },
+
+ 'POST /refresh': async (req, res) => {
+ if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
+ if (!current.configured) return json(res, 400, { error: 'Overflow client ID and API key are not set.' });
+ await current.refresh();
+ const st = current.getState();
+ if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' });
+ json(res, 200, { ok: true, groups: st.overflow.groups, destinations: st.overflow.destinations });
+ },
+
+ 'GET /groups': async (req, res) => {
+ if (!current) return json(res, 200, { options: [] });
+ if (current.configured && !current.groupOptions().length) await current.refresh();
+ json(res, 200, { options: current.groupOptions() });
+ },
+
+ 'GET /destinations': async (req, res) => {
+ if (!current) return json(res, 200, { options: [] });
+ if (current.configured && !current.destinationOptions().length) await current.refresh();
+ json(res, 200, { options: current.destinationOptions() });
+ },
+ };
+ },
+};
diff --git a/modules/overflow-tap/style.css b/modules/overflow-tap/style.css
new file mode 100644
index 0000000..9a43d86
--- /dev/null
+++ b/modules/overflow-tap/style.css
@@ -0,0 +1,213 @@
+/* Overflow Tap — scoped under its tile via [data-module="overflow-tap"] and
+ styled with the shell's variables only. Sizes hang off the tile (container
+ units) so the keyword reads from across a booth on a big tile and still
+ fits a 2×2. */
+
+[data-module="overflow-tap"] {
+ container-type: size;
+}
+
+[data-module="overflow-tap"] .ot-root {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ padding: clamp(4px, 4cqmin, 18px);
+ gap: clamp(2px, 1.8cqmin, 10px);
+ overflow: hidden;
+ color: var(--text);
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+[data-module="overflow-tap"] .ot-offline {
+ flex-shrink: 0;
+ font-size: clamp(0.6rem, 4.6cqmin, 0.9rem);
+ font-weight: 700;
+ color: var(--danger);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+[data-module="overflow-tap"] .ot-root.is-offline .ot-main,
+[data-module="overflow-tap"] .ot-root.is-offline .ot-slide,
+[data-module="overflow-tap"] .ot-root.is-offline .ot-history {
+ opacity: 0.45;
+ filter: grayscale(0.6);
+}
+
+/* ── the live keyword ───────────────────────────────────────────── */
+
+[data-module="overflow-tap"] .ot-main {
+ --tone: var(--accent);
+ flex: 1 1 auto;
+ min-height: 0;
+ container-type: size;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0;
+ text-align: center;
+ overflow: hidden;
+}
+[data-module="overflow-tap"] .ot-main[data-tone="ok"] { --tone: var(--accent); }
+[data-module="overflow-tap"] .ot-main[data-tone="warn"] { --tone: var(--warn); }
+[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); }
+[data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); }
+
+[data-module="overflow-tap"] .ot-label {
+ color: var(--tone);
+ font-size: clamp(0.5rem, 9cqh, 0.95rem);
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ line-height: 1.2;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+
+[data-module="overflow-tap"] .ot-keyword {
+ color: var(--tone);
+ font-size: min(clamp(1rem, 38cqh, 7rem), 18cqw);
+ font-weight: 750;
+ line-height: 1.15;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+[data-module="overflow-tap"] .ot-keyword.is-none { color: var(--muted); }
+[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-keyword {
+ animation: ot-pulse 1.2s ease-in-out infinite;
+}
+@keyframes ot-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.45; }
+}
+
+[data-module="overflow-tap"] .ot-dest {
+ color: var(--text);
+ font-size: min(clamp(0.7rem, 16cqh, 1.9rem), 8cqw);
+ font-weight: 600;
+ line-height: 1.2;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-dest { color: var(--muted); font-weight: 500; }
+
+[data-module="overflow-tap"] .ot-groups {
+ color: var(--muted);
+ font-size: clamp(0.5rem, 9cqh, 0.95rem);
+ line-height: 1.3;
+ flex-shrink: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+[data-module="overflow-tap"] .ot-error {
+ color: var(--danger);
+ font-size: clamp(0.5rem, 9cqh, 0.95rem);
+ line-height: 1.3;
+ max-width: 100%;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+[data-module="overflow-tap"] .ot-error.is-warn { color: var(--warn); }
+
+/* ── this slide ─────────────────────────────────────────────────── */
+
+[data-module="overflow-tap"] .ot-slide {
+ flex-shrink: 0;
+ text-align: center;
+ font-size: clamp(0.55rem, 4.8cqmin, 0.95rem);
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding: 0.25em 0.6em;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--text) 7%, transparent);
+ align-self: center;
+ max-width: 100%;
+}
+[data-module="overflow-tap"] .ot-slide.is-muted { color: var(--muted); }
+[data-module="overflow-tap"] .ot-slide.is-hit {
+ color: var(--accent-text);
+ background: color-mix(in srgb, var(--accent) 80%, transparent);
+ font-weight: 650;
+}
+[data-module="overflow-tap"] .ot-slide.is-warn {
+ color: var(--warn);
+ background: color-mix(in srgb, var(--warn) 16%, transparent);
+}
+
+/* ── the trail ──────────────────────────────────────────────────── */
+
+[data-module="overflow-tap"] .ot-history {
+ flex-shrink: 0;
+ list-style: none;
+ margin: 0;
+ padding: 0.3em 0 0;
+ border-top: 1px solid var(--border);
+ font-size: clamp(0.55rem, 4.6cqmin, 0.95rem);
+ display: flex;
+ flex-direction: column;
+ gap: 0.1em;
+}
+
+[data-module="overflow-tap"] .ot-h-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto;
+ align-items: baseline;
+ gap: 0 0.7em;
+ color: var(--text);
+}
+[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-kw,
+[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-mark { color: var(--danger); }
+[data-module="overflow-tap"] .ot-h-time {
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+[data-module="overflow-tap"] .ot-h-kw { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+[data-module="overflow-tap"] .ot-h-dest { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+[data-module="overflow-tap"] .ot-h-mark { color: var(--accent); font-weight: 700; }
+[data-module="overflow-tap"] .ot-h-empty { color: var(--muted); text-align: center; }
+
+/* On a short tile the trail would crowd the keyword out: keep the
+ keyword, drop the trail. Inside the keyword block the same rule: when
+ it is short, the small lines go before the keyword does. */
+@container (max-height: 120px) {
+ [data-module="overflow-tap"] .ot-history { display: none; }
+}
+@container (max-height: 70px) {
+ [data-module="overflow-tap"] .ot-groups,
+ [data-module="overflow-tap"] .ot-error { display: none; }
+}
+@container (max-height: 46px) {
+ [data-module="overflow-tap"] .ot-dest,
+ [data-module="overflow-tap"] .ot-label { display: none; }
+}
+
+[data-module="overflow-tap"] .ot-empty {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6%;
+ color: var(--muted);
+ text-align: center;
+ font-size: clamp(0.65rem, 5.5cqmin, 1rem);
+}
diff --git a/tools/overflow-tap-mock.js b/tools/overflow-tap-mock.js
new file mode 100644
index 0000000..024c4f4
--- /dev/null
+++ b/tools/overflow-tap-mock.js
@@ -0,0 +1,183 @@
+/**
+ * Mock ProPresenter slide status + mock Overflow Tap API, for developing the
+ * overflow-tap module without a live ProPresenter or an Overflow account.
+ *
+ * node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000]
+ * [--key=] [--read-only] [--slow]
+ *
+ * ProPresenter side (127.0.0.1:1602 by default) — the one route the module
+ * uses, with a service that advances every --step ms:
+ * GET /version reachability probe
+ * GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling
+ * through slides whose notes carry keywords
+ * ("give", "Next steps: connect", "prayer"), slides
+ * with other notes ("forgiveness" — must NOT fire
+ * "give" in whole-word mode), a slide whose keyword
+ * maps to nothing on the account, and a stretch with
+ * nothing live.
+ *
+ * Overflow side (127.0.0.1:24720 by default; run ProdDash with
+ * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the documented reads with
+ * paging, and the switch the module sends:
+ * GET /api/v3/tap/destinations four destinations
+ * GET /api/v3/tap/groups three groups, each pointing somewhere
+ * GET /api/v3/tap/groups/{id}
+ * PATCH /api/v3/tap/groups/{id} { destinationId } — moves the group.
+ * --read-only answers 404 instead, which
+ * is what Overflow's public API does
+ * today, to exercise the failure path.
+ * Every request must carry x-client-id and x-api-key (any non-empty values;
+ * --key= demands that exact key, answering 401 otherwise). --slow adds
+ * a two-second delay to every Overflow answer.
+ *
+ * Suggested admin Keywords for this mock:
+ * give = Give Online
+ * connect = Connect Card @ Lobby
+ * prayer = Prayer Request
+ * baptism = Baptism Signup
+ *
+ * Every incoming request is logged so we can confirm what the module does.
+ */
+'use strict';
+
+const http = require('http');
+
+const args = process.argv.slice(2);
+const opt = (name, dflt) => {
+ const a = args.find((x) => x.startsWith(`--${name}=`));
+ return a ? a.slice(name.length + 3) : dflt;
+};
+const PP_PORT = Number.parseInt(opt('pp', '1602'), 10);
+const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10);
+const STEP_MS = Number.parseInt(opt('step', '4000'), 10);
+const KEY = opt('key', '');
+const READ_ONLY = args.includes('--read-only');
+const SLOW = args.includes('--slow');
+
+/* ── ProPresenter ────────────────────────────────────────────────── */
+
+const SLIDES = [
+ { uuid: 'S1', text: 'Welcome to Waters Church', notes: '' },
+ { uuid: 'S2', text: 'Give online', notes: 'give' },
+ { uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' },
+ { uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' },
+ { uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' },
+ { uuid: 'S6', text: 'Pray with us', notes: 'prayer' },
+ { uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' },
+ null, // nothing live
+ { uuid: 'S8', text: 'Give online (again)', notes: 'give' },
+ { uuid: 'S9', text: 'See you next week', notes: '' },
+];
+let slideIdx = 0;
+setInterval(() => { slideIdx = (slideIdx + 1) % SLIDES.length; }, STEP_MS).unref();
+
+function slidePayload() {
+ const cur = SLIDES[slideIdx];
+ const nxt = SLIDES[(slideIdx + 1) % SLIDES.length];
+ return {
+ current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null,
+ next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null,
+ };
+}
+
+function sendJson(res, code, body, extra = {}) {
+ res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
+ res.end(JSON.stringify(body));
+}
+
+http.createServer((req, res) => {
+ const url = new URL(req.url, `http://${req.headers.host}`);
+ const p = url.pathname;
+ console.log(`[pp] ${req.method} ${p}`);
+ if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' });
+ if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload());
+ if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx]), media: false, announcements: false });
+ sendJson(res, 404, { error: 'not found' });
+}).listen(PP_PORT, '127.0.0.1', () => {
+ console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms)`);
+});
+
+/* ── Overflow ────────────────────────────────────────────────────── */
+
+const stamp = (d) => new Date(d).toISOString();
+const DESTINATIONS = [
+ { id: '6710f34fd5061afeec3eab01', name: 'Give Online', type: 'web', url: 'https://example.org/give', textNumber: null, textContent: null },
+ { id: '6710f34fd5061afeec3eab02', name: 'Connect Card', type: 'web', url: 'https://example.org/connect', textNumber: null, textContent: null },
+ { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request', type: 'web', url: 'https://example.org/pray', textNumber: null, textContent: null },
+ { id: '6710f34fd5061afeec3eab04', name: 'Events', type: 'sms', url: null, textNumber: '+15550100', textContent: 'EVENTS' },
+].map((d) => ({ ...d, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null }));
+
+const GROUPS = [
+ { id: '6710f34fd5061afeec3eab11', name: 'Auditorium', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab21', '6710f34fd5061afeec3eab22'] },
+ { id: '6710f34fd5061afeec3eab12', name: 'Lobby', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab23'] },
+ { id: '6710f34fd5061afeec3eab13', name: 'Kids Wing', destinationId: DESTINATIONS[1].id, deviceIds: [] },
+].map((g) => ({ ...g, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null }));
+
+function destView(id) {
+ const d = DESTINATIONS.find((x) => x.id === id);
+ return d ? { id: d.id, name: d.name, type: d.type, url: d.url, textNumber: d.textNumber, textContent: d.textContent } : null;
+}
+function groupView(g) {
+ return { ...g, destination: destView(g.destinationId) };
+}
+function page(list, url) {
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 25));
+ const pg = Math.max(1, Number(url.searchParams.get('page')) || 1);
+ const start = (pg - 1) * limit;
+ return { data: list.slice(start, start + limit), totalCount: list.length };
+}
+
+let served = 0;
+http.createServer((req, res) => {
+ const url = new URL(req.url, `http://${req.headers.host}`);
+ const p = url.pathname;
+ served += 1;
+ const rl = { 'x-ratelimit-limit': '120', 'x-ratelimit-remaining': String(Math.max(0, 120 - (served % 120))), 'x-ratelimit-reset': '59' };
+ const answer = (code, body) => {
+ const go = () => sendJson(res, code, body, rl);
+ if (SLOW) setTimeout(go, 2000);
+ else go();
+ };
+ const cid = String(req.headers['x-client-id'] || '');
+ const key = String(req.headers['x-api-key'] || '');
+ console.log(`[overflow] ${req.method} ${p}${url.search}${cid ? '' : ' (no client id)'}${key ? '' : ' (no api key)'}`);
+ if (!cid || !key || (KEY && key !== KEY)) return answer(401, { statusCode: 401, message: 'Unauthorized' });
+
+ if (req.method === 'GET' && p === '/api/v3/tap/destinations') return answer(200, page(DESTINATIONS, url));
+ const dOne = /^\/api\/v3\/tap\/destinations\/([^/]+)$/.exec(p);
+ if (req.method === 'GET' && dOne) {
+ const d = DESTINATIONS.find((x) => x.id === dOne[1]);
+ return d ? answer(200, { data: d }) : answer(404, { statusCode: 404, message: 'Destination not found' });
+ }
+ if (req.method === 'GET' && p === '/api/v3/tap/groups') return answer(200, page(GROUPS.map(groupView), url));
+ const gOne = /^\/api\/v3\/tap\/groups\/([^/]+)$/.exec(p);
+ if (gOne) {
+ const g = GROUPS.find((x) => x.id === gOne[1]);
+ if (!g) return answer(404, { statusCode: 404, message: 'Group not found' });
+ if (req.method === 'GET') return answer(200, { data: groupView(g) });
+ if (req.method === 'PATCH' || req.method === 'PUT') {
+ if (READ_ONLY) return answer(404, { statusCode: 404, message: `Cannot ${req.method} /api/v3/tap/groups/${g.id}` });
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ let parsed = {};
+ try { parsed = JSON.parse(body || '{}'); } catch { return answer(400, { statusCode: 400, message: 'Invalid JSON' }); }
+ const dest = DESTINATIONS.find((x) => x.id === parsed.destinationId);
+ if (!dest) return answer(400, { statusCode: 400, message: 'destinationId must be an existing destination' });
+ g.destinationId = dest.id;
+ g.updatedAt = stamp(Date.now());
+ console.log(`[overflow] ${g.name} → ${dest.name}`);
+ answer(200, { data: groupView(g) });
+ });
+ return;
+ }
+ }
+ if (req.method === 'GET' && p === '/api/v3/tap/devices') {
+ const devices = GROUPS.flatMap((g) => g.deviceIds.map((id) => ({ id, serialNumber: `TAP-${id.slice(-4)}`, groupId: g.id })));
+ return answer(200, page(devices, url));
+ }
+ if (req.method === 'GET' && p === '/api/v3/tap/events') return answer(200, { data: [], totalCount: 0 });
+ answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
+}).listen(OF_PORT, '127.0.0.1', () => {
+ console.log(`[overflow] mock Overflow API on http://127.0.0.1:${OF_PORT}${READ_ONLY ? ' (read-only: PATCH answers 404)' : ''}${KEY ? ' (api key required)' : ''}`);
+});