Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes

A new module that does what Overflow's "ProPresenter Integration" helper app
does, inside ProdDash: the live slide's notes are read from ProPresenter
(GET /v1/status/slide, twice a second) and when they carry one of the admin
page's keywords, the chosen Overflow Tap groups are pointed at that keyword's
destination. One tile: the keyword 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. A failed switch turns the tile
red with the reason, retries three times, and can flash an alert channel.

Admin config: ProPresenter endpoint + password; Overflow client ID + API key
(x-client-id / x-api-key, the public API's two headers); Tap groups to switch
(multiselect read from the account over /groups); keywords as text, one per
line (`give = Give Online`, optionally `@ Lobby, Kids Wing`); match mode
(whole word / anywhere / own line); alerts switch + channel.

The one unverified piece: Overflow's public API documents only reads for Tap
(OpenAPI checked 2026-10-07), while Overflow's helper uses the Dashboard's
private API. The switch is sent as PATCH /api/v3/tap/groups/{id} with the new
destinationId — the natural shape for this API — and every switch is confirmed
by reading the group back, so a refusal shows on the tile instead of passing
silently. overflow.js is the one place to change if Overflow names a
different route.

Also: tools/overflow-tap-mock.js (a ProPresenter whose slide notes carry
keywords on :1602 plus the Overflow Tap API on :24720, --read-only to fail
every switch), keywords.selftest.js (14 cases), README + CLAUDE.md entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-07 14:00:37 -04:00
co-authored by Claude Fable 5.1
parent c9e03398a6
commit 6b51044be6
10 changed files with 1692 additions and 2 deletions
+213
View File
@@ -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);
}