Timers: steadier menu, ellipsised labels, saner warning window

- The Timers ▾ menu's rows are rebuilt only when the row set or a switch
  changes, not on every snapshot — rows no longer vanish from under a
  click. Their on-state fill uses the shell's accent: the menu hangs off the
  title bar, outside the tile root, where the tile's colour properties
  don't reach.
- A long timer label ends in an ellipsis (the label is its own box inside
  the flex name row).
- The warning window is capped at half a timer's length, so a 20 s
  countdown isn't amber for its whole run (it warns under 10 s; a 25 min
  one under 2:30).
- Server: the change key that gates SSE frames ignores startedAt (derived
  from a whole-second value, it jittered ±500 ms per poll and doubled the
  frame rate); ProPresenter errors name the cause (ECONNREFUSED) rather
  than fetch's "fetch failed".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-13 13:24:46 -04:00
co-authored by Claude Fable 5.1
parent 1e92454e76
commit 555bc482ce
3 changed files with 46 additions and 25 deletions
+29 -18
View File
@@ -22,8 +22,9 @@
Feedback — the state colours, and the thresholds behind them:
running elapsed, no planned length running
running elapsed with a planned length warning within
WARN_FLOOR_MS or WARN_FRACTION of the length (whichever is larger)
of the end; overrun once past it
WARN_FLOOR_MS or WARN_FRACTION of the length (whichever is larger,
but never more than half the length — a 20 s countdown warns
under 10 s, a 25 min one under 2:30) of the end; overrun past it
running countdown warning with that much
left; overrun at zero and below
stopped countdown at zero ("complete") warning
@@ -130,7 +131,8 @@ export function fmtDuration(ms) {
}
function warnWindow(targetMs) {
return targetMs ? Math.max(WARN_FLOOR_MS, targetMs * WARN_FRACTION) : WARN_FLOOR_MS;
if (!targetMs) return WARN_FLOOR_MS;
return Math.min(Math.max(WARN_FLOOR_MS, targetMs * WARN_FRACTION), targetMs / 2);
}
/** The state colour for a (non-clock) timer given its current value. */
@@ -242,9 +244,10 @@ export default function create({ root, moduleApi }) {
label: 'Timers ▾',
title: 'Choose which timers this tile shows',
build(menu) {
// the shell hands over an emptied element on every open
menu.classList.add('tm-menu');
menuEl = menu;
rebuildMenu();
rebuildMenu(true);
},
});
@@ -277,11 +280,18 @@ export default function create({ root, moduleApi }) {
itemsMenu.setLabel(visible === rows.length ? 'Timers ▾' : `Timers (${visible}/${rows.length}) ▾`);
}
function rebuildMenu() {
let menuSignature = '';
function rebuildMenu(force = false) {
updateMenuLabel();
if (!menuEl) return; // menu not opened yet — the label is enough
menuEl.innerHTML = '';
const rows = menuRows();
// Snapshots arrive several times a second; rebuilding the open menu's
// rows each time would replace them under the user's finger. Only a
// changed row set (or a toggled switch) is worth touching the DOM for.
const signature = JSON.stringify(rows.map((r) => [r.key, r.text, hidden.has(r.key)]));
if (!force && signature === menuSignature && menuEl.childElementCount) return;
menuSignature = signature;
menuEl.innerHTML = '';
if (!rows.length) {
const note = document.createElement('div');
note.className = 'tm-menu-note';
@@ -356,6 +366,9 @@ export default function create({ root, moduleApi }) {
el.className = 'tm-card';
const nameEl = document.createElement('div');
nameEl.className = 'tm-name';
const labelEl = document.createElement('span');
labelEl.className = 'tm-label';
nameEl.appendChild(labelEl);
const timeEl = document.createElement('div');
timeEl.className = 'tm-time';
const statusEl = document.createElement('div');
@@ -363,7 +376,7 @@ export default function create({ root, moduleApi }) {
const detailEl = document.createElement('div');
detailEl.className = 'tm-detail';
el.append(nameEl, timeEl, statusEl, detailEl);
return { key: item.key, el, nameEl, timeEl, statusEl, detailEl, item, chars: 5, cls: '' };
return { key: item.key, el, nameEl, labelEl, timeEl, statusEl, detailEl, item, chars: 5, cls: '' };
}
function setText(el, text) {
@@ -380,18 +393,16 @@ export default function create({ root, moduleApi }) {
function updateCard(card, item) {
card.item = item;
const isLtc = item.source?.kind === 'ltc';
if (isLtc && !card.nameEl.querySelector('.tm-ltc-badge')) {
const badge = document.createElement('span');
badge.className = 'tm-ltc-badge';
badge.textContent = 'LTC';
card.nameEl.replaceChildren(badge, document.createTextNode(''));
} else if (!isLtc && card.nameEl.querySelector('.tm-ltc-badge')) {
card.nameEl.replaceChildren(document.createTextNode(''));
const badge = card.nameEl.querySelector('.tm-ltc-badge');
if (isLtc && !badge) {
const b = document.createElement('span');
b.className = 'tm-ltc-badge';
b.textContent = 'LTC';
card.nameEl.prepend(b);
} else if (!isLtc && badge) {
badge.remove();
}
const label = item.missing ? (lastSoloLabel || 'Timer') : item.timer.label;
const textNode = card.nameEl.lastChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) setText(textNode, label);
else card.nameEl.appendChild(document.createTextNode(label));
setText(card.labelEl, item.missing ? (lastSoloLabel || 'Timer') : item.timer.label);
}
function updateMissingCard(card) {
+5 -4
View File
@@ -402,7 +402,7 @@ function createProPresenterSource({ config, log }) {
lastError = '';
} catch (err) {
reachable = false;
lastError = err instanceof Error ? err.message : String(err);
lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
}
publish();
if (stopped) return;
@@ -949,11 +949,12 @@ module.exports = {
function rebuild() {
if (stopped) return;
latest = buildState();
// updatedAt / serverNow move on every poll; the tiles extrapolate from
// them but don't need a frame for that alone.
// updatedAt / serverNow move on every poll, and a startedAt derived
// from a whole-second value jitters with them; the tiles extrapolate
// from the values but don't need a frame for that alone.
let key;
try {
key = JSON.stringify(latest.sources.map((v) => [v.id, v.label, v.status, v.message, v.timers]));
key = JSON.stringify(latest.sources.map((v) => [v.id, v.label, v.status, v.message, v.timers.map(({ startedAt, ...rest }) => rest)]));
} catch {
return;
}
+12 -3
View File
@@ -164,8 +164,15 @@
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
}
/* The label is its own box so a long name ends in an ellipsis (a flex
container's bare text won't). */
[data-module="propresenter-timers"] .tm-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-module="propresenter-timers"] .tm-time {
@@ -306,7 +313,9 @@
[data-module="propresenter-timers"] .tm-menu-item .name { font-weight: 600; }
[data-module="propresenter-timers"] .tm-menu-item:not(.on) .name { color: var(--muted); }
/* A small switch: off = hollow pill, on = filled with the running colour. */
/* A small switch: off = hollow pill, on = filled with the accent. (The menu
hangs off the tile's title bar, outside .tm-wrap, so the tile's own colour
properties don't reach it — the shell's palette does.) */
[data-module="propresenter-timers"] .tm-switch {
flex: none;
position: relative;
@@ -329,8 +338,8 @@
transition: transform 0.15s ease;
}
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch {
border-color: var(--tm-ok);
background: var(--tm-ok);
border-color: var(--accent);
background: var(--accent);
}
[data-module="propresenter-timers"] .tm-menu-item.on .tm-switch::after {
transform: translateX(12px);