ProdCom: direction toggle, icon channels button, leaner tile settings

Three changes to the ProdCom tile, all per-tile and persisted with the
layout (version 1.2.0):

- A title-bar button flips the transcript between top-down (newest at the
  bottom, still the default) and bottom-up. It is CSS only — the entries
  container becomes column-reverse, DOM order is untouched — but everything
  that follows "latest" flips with it: auto-follow, jump-to-latest, the
  near-latest scroll test, and the "New messages" pill, which moves to the
  top of the tile and points up.

- The "Channels ▾" text button is now a funnel icon. It keeps what the
  label carried: while any channel is hidden the icon takes the accent
  color and a compact "3/4" count sits beside it, with the full wording in
  the tooltip.

- Tile settings drop the channel-name filter and text size, both of which
  the title bar already handles, and gain "Show channel icons" for the
  per-entry avatars. Removing the channel filter made its code dead, so
  channelFilter(), filteredOut() and the data-channel-name attribute went
  with it — the menubar's hidden-channel set is now the only filter.

Text size keeps working without a schema entry: the A−/A+ buttons persist
it through saveInstanceSettings, the shell's settings popover only writes
schema keys so it survives an Apply, and the client falls back to 15px when
it is absent. Existing tiles have no saved showAvatars value and the check
is `=== false`, so they keep their icons.

Verified against tools/prodcom-mock.js: both directions render and
auto-follow correctly, the pill appears at the right end and jumps to the
newest entry, the count badge tracks the channel toggles, a tile bumped to
19px kept it across an Apply and a reload, a freshly added tile started at
15px with icons shown, and two tiles held independent state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-10 16:40:46 -04:00
co-authored by Claude Opus 5
parent 10ac7d1e09
commit 5ecc39efe2
4 changed files with 106 additions and 54 deletions
+3 -1
View File
@@ -168,8 +168,10 @@ below. Nothing is installed without someone clicking.
- **ProdCom** — live transcript stream with in-place updates of in-progress - **ProdCom** — live transcript stream with in-place updates of in-progress
speech, channel colors, per-tile channel visibility toggles and group speech, channel colors, per-tile channel visibility toggles and group
presets, timestamps, text size and jump-to-latest in the title bar. presets, timestamps, text size, a top-down / bottom-up direction toggle
and jump-to-latest in the title bar.
Admin config: ProdCom server (host : port), API key. Admin config: ProdCom server (host : port), API key.
Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
Admin config: ProPresenter endpoint (host : port), password. Admin config: ProPresenter endpoint (host : port), password.
+70 -44
View File
@@ -2,9 +2,9 @@
Ported from prodcom-listener/public/app.js into the ProdDash module Ported from prodcom-listener/public/app.js into the ProdDash module
contract: everything lives inside the tile's root, per-tile state contract: everything lives inside the tile's root, per-tile state
(hidden channels, timestamps, text size) persists in instance settings, (hidden channels, channel icons, timestamps, text size, flow direction)
and all network traffic goes through moduleApi (the module's server persists in instance settings, and all network traffic goes through
proxy at /prodcom/*). */ moduleApi (the module's server proxy at /prodcom/*). */
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* ── per-instance state ─────────────────────────────────────────── */ /* ── per-instance state ─────────────────────────────────────────── */
@@ -12,7 +12,7 @@ export default function create({ root, moduleApi }) {
const channels = new Map(); // channelId -> {name, color} const channels = new Map(); // channelId -> {name, color}
const groups = new Map(); // groupId -> {name, channelIds} const groups = new Map(); // groupId -> {name, channelIds}
const entryEls = new Map(); // entryId -> element const entryEls = new Map(); // entryId -> element
let pinnedToBottom = true; let pinnedToLatest = true;
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []); let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
let stream = null; // moduleApi.sse handle let stream = null; // moduleApi.sse handle
let bootTimer = null; let bootTimer = null;
@@ -40,10 +40,14 @@ export default function create({ root, moduleApi }) {
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'; const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>'; const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>'; const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
const CHANNELS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 7.4V19l-4 2v-8.6z"/></svg>';
// arrow shows where new messages land: down = oldest first, up = newest first
const FLOW_DOWN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v14"/><path d="m3 15 3 3 3-3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
const FLOW_UP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 20V6"/><path d="m3 9 3-3 3 3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
let channelMenuEl = null; // the open menu's element (filled on each open) let channelMenuEl = null; // the open menu's element (filled on each open)
const channelsMenu = moduleApi.header.addMenu({ const channelsMenu = moduleApi.header.addMenu({
label: 'Channels ▾', icon: CHANNELS_SVG,
title: 'Choose visible channels', title: 'Choose visible channels',
build(menu) { build(menu) {
menu.classList.add('pt-menu'); menu.classList.add('pt-menu');
@@ -52,6 +56,11 @@ export default function create({ root, moduleApi }) {
}, },
}); });
channelsMenu.button.classList.add('pt-channels-btn'); channelsMenu.button.classList.add('pt-channels-btn');
// the icon carries the label; a count rides alongside it while filtering
const channelCountEl = document.createElement('span');
channelCountEl.className = 'pt-count';
channelCountEl.hidden = true;
channelsMenu.button.appendChild(channelCountEl);
let timeBtn = null; let timeBtn = null;
timeBtn = moduleApi.header.addButton({ timeBtn = moduleApi.header.addButton({
@@ -71,13 +80,24 @@ export default function create({ root, moduleApi }) {
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) }); moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) }); moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
// icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi.header.addButton({
icon: FLOW_DOWN_SVG,
onClick() {
moduleApi.saveInstanceSettings({ newestFirst: !moduleApi.instanceSettings.newestFirst });
applyInstanceSettings();
// the two directions have opposite "latest" ends — follow the flip
if (pinnedToLatest) scrollToLatest();
},
});
moduleApi.header.addButton({ moduleApi.header.addButton({
icon: JUMP_SVG, icon: JUMP_SVG,
title: 'Jump to latest', title: 'Jump to latest',
onClick() { onClick() {
pinnedToBottom = true; pinnedToLatest = true;
jumpBtn.hidden = true; jumpBtn.hidden = true;
scrollToBottom(); scrollToLatest();
}, },
}); });
@@ -93,25 +113,20 @@ export default function create({ root, moduleApi }) {
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15)); const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
wrap.style.setProperty('--pt-size', size + 'px'); wrap.style.setProperty('--pt-size', size + 'px');
wrap.classList.toggle('show-times', Boolean(s.showTimes)); wrap.classList.toggle('show-times', Boolean(s.showTimes));
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
timeBtn?.classList.toggle('active', Boolean(s.showTimes)); timeBtn?.classList.toggle('active', Boolean(s.showTimes));
const up = newestFirst();
wrap.classList.toggle('newest-first', up);
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
flowBtn.classList.toggle('active', up);
flowBtn.title = up
? 'Newest at top — click for newest at bottom'
: 'Newest at bottom — click for newest at top';
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
} }
applyInstanceSettings(); applyInstanceSettings();
/* ── channel filter (instance setting) ──────────────────────────── */
function channelFilter() {
return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase();
}
/** Entry hidden by the hard per-tile filter (settings.channel)? */
function filteredOut(channelId, channelName) {
const want = channelFilter();
if (!want) return false;
const known = channels.get(channelId);
const name = ((known && known.name) || channelName || '').toLowerCase();
return name !== want && String(channelId).toLowerCase() !== want;
}
/* ── channel visibility toggles ─────────────────────────────────── */ /* ── channel visibility toggles ─────────────────────────────────── */
function persistHidden() { function persistHidden() {
@@ -119,9 +134,7 @@ export default function create({ root, moduleApi }) {
} }
function applyFilter(el) { function applyFilter(el) {
const hidden = hiddenChannels.has(el.dataset.channelId) el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
|| filteredOut(el.dataset.channelId, el.dataset.channelName);
el.classList.toggle('hidden-by-filter', hidden);
} }
function toggleChannel(id) { function toggleChannel(id) {
@@ -130,7 +143,7 @@ export default function create({ root, moduleApi }) {
persistHidden(); persistHidden();
rebuildChannelMenu(); rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el)); entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
} }
/** Show exactly this group's channels, hide the rest */ /** Show exactly this group's channels, hide the rest */
@@ -139,7 +152,7 @@ export default function create({ root, moduleApi }) {
persistHidden(); persistHidden();
rebuildChannelMenu(); rebuildChannelMenu();
entryEls.forEach((el) => applyFilter(el)); entryEls.forEach((el) => applyFilter(el));
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
} }
function groupIsActive(g) { function groupIsActive(g) {
@@ -150,18 +163,24 @@ export default function create({ root, moduleApi }) {
); );
} }
function updateChannelsLabel() { /* The button is an icon, so the "some channels are hidden" state shows as
an accent tint plus a compact count beside it. */
function updateChannelsButton() {
let visibleCount = 0; let visibleCount = 0;
for (const id of channels.keys()) { for (const id of channels.keys()) {
if (!hiddenChannels.has(id)) visibleCount++; if (!hiddenChannels.has(id)) visibleCount++;
} }
channelsMenu.setLabel(visibleCount === channels.size const filtering = channels.size > 0 && visibleCount !== channels.size;
? 'Channels ▾' channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
: `Channels (${visibleCount}/${channels.size}) ▾`); channelCountEl.hidden = !filtering;
channelsMenu.button.classList.toggle('active', filtering);
channelsMenu.button.title = filtering
? `Channels — showing ${visibleCount} of ${channels.size}`
: 'Choose visible channels';
} }
function rebuildChannelMenu() { function rebuildChannelMenu() {
updateChannelsLabel(); updateChannelsButton();
const channelMenu = channelMenuEl; const channelMenu = channelMenuEl;
if (!channelMenu) return; // menu not opened yet — the label is enough if (!channelMenu) return; // menu not opened yet — the label is enough
channelMenu.innerHTML = ''; channelMenu.innerHTML = '';
@@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) {
/* ── scrolling ──────────────────────────────────────────────────── */ /* ── scrolling ──────────────────────────────────────────────────── */
/** Newest entry at the top (bottom-up) instead of at the bottom? */
function newestFirst() {
return Boolean(moduleApi.instanceSettings.newestFirst);
}
streamEl.addEventListener('scroll', () => { streamEl.addEventListener('scroll', () => {
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60; const near = newestFirst()
pinnedToBottom = nearBottom; ? streamEl.scrollTop < 60
if (nearBottom) jumpBtn.hidden = true; : streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
pinnedToLatest = near;
if (near) jumpBtn.hidden = true;
}); });
jumpBtn.addEventListener('click', () => { jumpBtn.addEventListener('click', () => {
pinnedToBottom = true; pinnedToLatest = true;
jumpBtn.hidden = true; jumpBtn.hidden = true;
scrollToBottom(); scrollToLatest();
}); });
function scrollToBottom() { /** The latest entry is at the top when flipped, at the bottom otherwise. */
streamEl.scrollTop = streamEl.scrollHeight; function scrollToLatest() {
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
} }
function afterInsert(el) { function afterInsert(el) {
if (el.classList.contains('hidden-by-filter')) return; if (el.classList.contains('hidden-by-filter')) return;
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
else jumpBtn.hidden = false; else jumpBtn.hidden = false;
} }
@@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) {
el = document.createElement('div'); el = document.createElement('div');
el.className = 'pt-entry'; el.className = 'pt-entry';
el.dataset.channelId = entry.channelId || ''; el.dataset.channelId = entry.channelId || '';
el.dataset.channelName = entry.channelName || '';
const info = channelInfo(entry); const info = channelInfo(entry);
el.style.setProperty('--ch', info.color); el.style.setProperty('--ch', info.color);
@@ -314,7 +340,7 @@ export default function create({ root, moduleApi }) {
afterInsert(el); afterInsert(el);
} else { } else {
updateEntryEl(el, entry); updateEntryEl(el, entry);
if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom(); if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest();
} }
} }
@@ -430,7 +456,7 @@ export default function create({ root, moduleApi }) {
bootTimer = setTimeout(boot, 5000); bootTimer = setTimeout(boot, 5000);
return; return;
} }
scrollToBottom(); scrollToLatest();
connect(); connect();
} }
@@ -445,7 +471,7 @@ export default function create({ root, moduleApi }) {
root.innerHTML = ''; // header controls are removed by the shell root.innerHTML = ''; // header controls are removed by the shell
}, },
onResize() { onResize() {
if (pinnedToBottom) scrollToBottom(); if (pinnedToLatest) scrollToLatest();
}, },
}; };
} }
+9 -9
View File
@@ -1,7 +1,7 @@
{ {
"id": "prodcom-transcript", "id": "prodcom-transcript",
"name": "ProdCom", "name": "ProdCom",
"version": "1.0.0", "version": "1.2.0",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Live ProdCom transcript stream", "description": "Live ProdCom transcript stream",
"client": "client.js", "client": "client.js",
@@ -22,15 +22,15 @@
} }
}, },
"instanceSchema": { "instanceSchema": {
"channel": { "showAvatars": {
"type": "string", "type": "boolean",
"label": "Channel filter (channel name; empty = all)", "label": "Show channel icons",
"default": "" "default": true
}, },
"textSize": { "newestFirst": {
"type": "number", "type": "boolean",
"label": "Text size (px)", "label": "Newest first (transcript reads bottom-up)",
"default": 15 "default": false
}, },
"showTimes": { "showTimes": {
"type": "boolean", "type": "boolean",
+24
View File
@@ -9,6 +9,17 @@
flex-direction: column; flex-direction: column;
} }
/* ── channels button ──
Icon-only; the count appears next to it while some channels are hidden. */
[data-module="prodcom-transcript"] .pt-channels-btn { gap: 3px; }
[data-module="prodcom-transcript"] .pt-count {
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
/* ── channels menu ── /* ── channels menu ──
The menu container is created and positioned by the shell (.tile-menu); The menu container is created and positioned by the shell (.tile-menu);
it lives inside the tile, so [data-module] scoping still applies. */ it lives inside the tile, so [data-module] scoping still applies. */
@@ -113,6 +124,7 @@
background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent); background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent);
} }
[data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; } [data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; }
[data-module="prodcom-transcript"] .hide-avatars .pt-entry .avatar { display: none; }
[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; } [data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; }
@@ -143,6 +155,12 @@
[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; } [data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; }
/* bottom-up: newest entry at the top, the stream reading upwards */
[data-module="prodcom-transcript"] .newest-first .pt-entries {
display: flex;
flex-direction: column-reverse;
}
/* ── jump-to-latest ── */ /* ── jump-to-latest ── */
[data-module="prodcom-transcript"] .pt-jump { [data-module="prodcom-transcript"] .pt-jump {
@@ -163,3 +181,9 @@
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
} }
[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; } [data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; }
/* flipped, "latest" is the top of the stream — so is the pill */
[data-module="prodcom-transcript"] .newest-first .pt-jump {
top: 12px;
bottom: auto;
}