diff --git a/modules/prodcom-transcript/client.js b/modules/prodcom-transcript/client.js
new file mode 100644
index 0000000..8d85ca2
--- /dev/null
+++ b/modules/prodcom-transcript/client.js
@@ -0,0 +1,403 @@
+/* ProdCom Transcript — client part.
+
+ Ported from prodcom-listener/public/app.js into the ProdDash module
+ contract: everything lives inside the tile's root, per-tile state
+ (hidden channels, timestamps, text size) persists in instance settings,
+ and all network traffic goes through moduleApi (the module's server
+ proxy at /prodcom/*). */
+
+export default function create({ root, moduleApi }) {
+ /* ── per-instance state ─────────────────────────────────────────── */
+
+ const channels = new Map(); // channelId -> {name, color}
+ const groups = new Map(); // groupId -> {name, channelIds}
+ const entryEls = new Map(); // entryId -> element
+ let pinnedToBottom = true;
+ let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
+ let stream = null; // moduleApi.sse handle
+ let bootTimer = null;
+ let stopped = false;
+
+ /* ── DOM skeleton ───────────────────────────────────────────────── */
+
+ root.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
Waiting for transcript…
+
+
+
+
`;
+
+ const wrap = root.querySelector('.pt-wrap');
+ const streamEl = root.querySelector('.pt-stream');
+ const entriesEl = root.querySelector('.pt-entries');
+ const emptyEl = root.querySelector('.pt-empty');
+ const jumpBtn = root.querySelector('.pt-jump');
+ const channelBtn = root.querySelector('.pt-channels-btn');
+ const channelMenu = root.querySelector('.pt-menu');
+
+ function applyInstanceSettings() {
+ const s = moduleApi.instanceSettings;
+ const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
+ wrap.style.setProperty('--pt-size', size + 'px');
+ wrap.classList.toggle('show-times', Boolean(s.showTimes));
+ }
+ 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 ─────────────────────────────────── */
+
+ function persistHidden() {
+ moduleApi.saveInstanceSettings({ hiddenChannels: [...hiddenChannels] });
+ }
+
+ function applyFilter(el) {
+ const hidden = hiddenChannels.has(el.dataset.channelId)
+ || filteredOut(el.dataset.channelId, el.dataset.channelName);
+ el.classList.toggle('hidden-by-filter', hidden);
+ }
+
+ function toggleChannel(id) {
+ if (hiddenChannels.has(id)) hiddenChannels.delete(id);
+ else hiddenChannels.add(id);
+ persistHidden();
+ rebuildChannelMenu();
+ entryEls.forEach((el) => applyFilter(el));
+ if (pinnedToBottom) scrollToBottom();
+ }
+
+ /** Show exactly this group's channels, hide the rest */
+ function applyGroup(g) {
+ hiddenChannels = new Set([...channels.keys()].filter((cid) => !g.channelIds.includes(cid)));
+ persistHidden();
+ rebuildChannelMenu();
+ entryEls.forEach((el) => applyFilter(el));
+ if (pinnedToBottom) scrollToBottom();
+ }
+
+ function groupIsActive(g) {
+ const members = g.channelIds.filter((cid) => channels.has(cid));
+ if (!members.length) return false;
+ return [...channels.keys()].every((cid) =>
+ members.includes(cid) ? !hiddenChannels.has(cid) : hiddenChannels.has(cid)
+ );
+ }
+
+ function rebuildChannelMenu() {
+ channelMenu.innerHTML = '';
+
+ if (groups.size) {
+ for (const g of groups.values()) {
+ const on = groupIsActive(g);
+ const item = document.createElement('button');
+ item.className = 'pt-channel-item pt-group-item' + (on ? ' on' : '');
+ item.setAttribute('role', 'menuitemradio');
+ item.setAttribute('aria-checked', String(on));
+ const check = document.createElement('span');
+ check.className = 'check';
+ check.textContent = '✓';
+ const name = document.createElement('span');
+ name.className = 'name';
+ name.textContent = g.name;
+ item.append(check, name);
+ item.addEventListener('click', (e) => {
+ e.stopPropagation();
+ applyGroup(g);
+ });
+ channelMenu.appendChild(item);
+ }
+ const divider = document.createElement('div');
+ divider.className = 'pt-menu-divider';
+ channelMenu.appendChild(divider);
+ }
+
+ let visibleCount = 0;
+ for (const [id, info] of channels) {
+ const on = !hiddenChannels.has(id);
+ if (on) visibleCount++;
+ const item = document.createElement('button');
+ item.className = 'pt-channel-item' + (on ? ' on' : '');
+ item.setAttribute('role', 'menuitemcheckbox');
+ item.setAttribute('aria-checked', String(on));
+ const check = document.createElement('span');
+ check.className = 'check';
+ check.textContent = '✓';
+ const name = document.createElement('span');
+ name.className = 'name';
+ name.textContent = info.name;
+ name.style.color = info.color;
+ item.append(check, name);
+ item.addEventListener('click', (e) => {
+ // keep the menu open: the rebuild detaches this item, which would
+ // make the outside-click handler think we clicked outside
+ e.stopPropagation();
+ toggleChannel(id);
+ });
+ channelMenu.appendChild(item);
+ }
+ channelBtn.textContent = visibleCount === channels.size
+ ? 'Channels ▾'
+ : `Channels (${visibleCount}/${channels.size}) ▾`;
+ }
+
+ channelBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ channelMenu.hidden = !channelMenu.hidden;
+ });
+ const onDocClick = (e) => {
+ if (!channelMenu.hidden && !e.target.closest('.pt-dd')) channelMenu.hidden = true;
+ };
+ document.addEventListener('click', onDocClick);
+
+ /* ── clear (local view only) ────────────────────────────────────── */
+
+ root.querySelector('.pt-clear').addEventListener('click', () => {
+ entryEls.forEach((el) => el.remove());
+ entryEls.clear();
+ emptyEl.hidden = false;
+ });
+
+ /* ── scrolling ──────────────────────────────────────────────────── */
+
+ streamEl.addEventListener('scroll', () => {
+ const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
+ pinnedToBottom = nearBottom;
+ if (nearBottom) jumpBtn.hidden = true;
+ });
+
+ jumpBtn.addEventListener('click', () => {
+ pinnedToBottom = true;
+ jumpBtn.hidden = true;
+ scrollToBottom();
+ });
+
+ function scrollToBottom() {
+ streamEl.scrollTop = streamEl.scrollHeight;
+ }
+
+ function afterInsert(el) {
+ if (el.classList.contains('hidden-by-filter')) return;
+ if (pinnedToBottom) scrollToBottom();
+ else jumpBtn.hidden = false;
+ }
+
+ /* ── entry rendering ────────────────────────────────────────────── */
+
+ function channelInfo(entry) {
+ const known = channels.get(entry.channelId);
+ return {
+ name: (known && known.name) || entry.channelName || 'Unknown',
+ color: (known && known.color) || 'var(--muted)',
+ };
+ }
+
+ function fmtTime(iso) {
+ const d = iso ? new Date(iso) : new Date();
+ return isNaN(d) ? '' : d.toLocaleTimeString([], { hour12: false });
+ }
+
+ function upsertEntry(entry) {
+ if (!entry || typeof entry.text !== 'string') return;
+ const id = entry.id || entry.channelId + '|' + entry.date;
+
+ let el = entryEls.get(id);
+ if (!el) {
+ emptyEl.hidden = true;
+ el = document.createElement('div');
+ el.className = 'pt-entry';
+ el.dataset.channelId = entry.channelId || '';
+ el.dataset.channelName = entry.channelName || '';
+ const info = channelInfo(entry);
+ el.style.setProperty('--ch', info.color);
+
+ const time = document.createElement('span');
+ time.className = 'time';
+ time.textContent = fmtTime(entry.date);
+
+ const avatar = document.createElement('span');
+ avatar.className = 'avatar';
+ avatar.style.color = info.color;
+ avatar.innerHTML =
+ '';
+
+ const ch = document.createElement('span');
+ ch.className = 'channel';
+ ch.textContent = info.name;
+ ch.style.color = info.color;
+
+ const text = document.createElement('span');
+ text.className = 'text';
+
+ const body = document.createElement('div');
+ body.className = 'body';
+ body.append(ch, text);
+
+ el.append(time, avatar, body);
+ entryEls.set(id, el);
+ applyFilter(el);
+ entriesEl.appendChild(el);
+ updateEntryEl(el, entry);
+ afterInsert(el);
+ } else {
+ updateEntryEl(el, entry);
+ if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom();
+ }
+ }
+
+ function updateEntryEl(el, entry) {
+ el.querySelector('.text').textContent = entry.translatedText || entry.text;
+ el.classList.toggle('in-progress', entry.inProgress === true);
+ }
+
+ /* ── SSE event parsing ──────────────────────────────────────────── */
+
+ /* The stream sends JSON objects for new/updated/completed transcript
+ entries. Be liberal about the envelope shape. */
+ function extractEntries(obj) {
+ if (!obj || typeof obj !== 'object') return [];
+ if (Array.isArray(obj)) return obj.flatMap(extractEntries);
+ if (typeof obj.text === 'string' && (obj.id || obj.channelId)) return [obj];
+ for (const key of ['data', 'payload', 'entry', 'entries', 'transcript']) {
+ if (obj[key] && typeof obj[key] === 'object') {
+ const found = extractEntries(obj[key]);
+ if (found.length) return found;
+ }
+ }
+ return [];
+ }
+
+ function handleEventData(raw) {
+ let obj;
+ try { obj = JSON.parse(raw); } catch { return; }
+ const kind = String(obj.type || obj.event || '').toLowerCase();
+ if (kind.includes('clear')) {
+ entryEls.forEach((el) => el.remove());
+ entryEls.clear();
+ emptyEl.hidden = false;
+ return;
+ }
+ extractEntries(obj).forEach(upsertEntry);
+ }
+
+ /* ── data loading (all via the module's proxy) ──────────────────── */
+
+ async function loadChannels() {
+ const res = await moduleApi.fetch('/prodcom/api/v1/channels');
+ if (!res.ok) throw new Error('channels ' + res.status);
+ const body = await res.json();
+ channels.clear();
+ for (const ch of body.data || []) {
+ channels.set(ch.id, { name: ch.name, color: ch.color || '#8b98a5' });
+ }
+ rebuildChannelMenu();
+ }
+
+ async function loadGroups() {
+ const res = await moduleApi.fetch('/prodcom/api/v1/groups');
+ if (!res.ok) throw new Error('groups ' + res.status);
+ const body = await res.json();
+ groups.clear();
+ for (const g of body.data || []) {
+ groups.set(g.id, { name: g.name, channelIds: g.channelIds || [] });
+ }
+ rebuildChannelMenu();
+ }
+
+ async function loadHistory() {
+ const res = await moduleApi.fetch('/prodcom/api/v1/transcript?limit=100');
+ if (!res.ok) throw new Error('transcript ' + res.status);
+ const body = await res.json();
+ const entries = (body.data || []).slice();
+ entries.sort((a, b) => new Date(a.date) - new Date(b.date));
+ entries.forEach(upsertEntry);
+ }
+
+ /* ── live stream ────────────────────────────────────────────────── */
+
+ const SSE_EVENT_NAMES = [
+ 'transcript', 'transcript.new', 'transcript.updated', 'transcript.update',
+ 'transcript.completed', 'transcript.complete', 'entry', 'new', 'update',
+ 'updated', 'complete', 'completed',
+ ];
+
+ function connect() {
+ const onEvent = (ev) => handleEventData(ev.data);
+ const events = {};
+ for (const name of SSE_EVENT_NAMES) events[name] = onEvent;
+
+ stream = moduleApi.sse('/prodcom/api/v1/transcript/stream', {
+ open() {
+ moduleApi.setStatus('ok', 'Live');
+ // refresh channels/groups and backfill entries missed while
+ // disconnected (upsertEntry dedupes by id, so this is harmless)
+ loadChannels().catch(() => {});
+ loadGroups().catch(() => {});
+ loadHistory().catch(() => {});
+ },
+ error() {
+ moduleApi.setStatus('error', 'ProdCom unreachable — reconnecting…');
+ },
+ message: onEvent,
+ events,
+ });
+ }
+
+ /* ── lifecycle ──────────────────────────────────────────────────── */
+
+ async function boot() {
+ if (stopped) return;
+ moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
+ try {
+ await loadChannels();
+ await loadGroups().catch(() => {}); // groups are optional
+ await loadHistory();
+ } catch {
+ moduleApi.setStatus('error', 'ProdCom unreachable — retrying…');
+ bootTimer = setTimeout(boot, 5000);
+ return;
+ }
+ scrollToBottom();
+ connect();
+ }
+
+ return {
+ start() {
+ boot();
+ },
+ stop() {
+ stopped = true;
+ clearTimeout(bootTimer);
+ stream?.close();
+ document.removeEventListener('click', onDocClick);
+ root.innerHTML = '';
+ },
+ onResize() {
+ if (pinnedToBottom) scrollToBottom();
+ },
+ };
+}
diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json
new file mode 100644
index 0000000..8e05663
--- /dev/null
+++ b/modules/prodcom-transcript/module.json
@@ -0,0 +1,40 @@
+{
+ "id": "prodcom-transcript",
+ "name": "ProdCom Transcript",
+ "version": "1.0.0",
+ "description": "Live ProdCom transcript stream",
+ "client": "client.js",
+ "server": "server.js",
+ "style": "style.css",
+ "minSize": { "w": 3, "h": 2 },
+ "defaultSize": { "w": 4, "h": 4 },
+ "configSchema": {
+ "prodcomUrl": {
+ "type": "string",
+ "label": "ProdCom URL",
+ "default": "http://10.3.11.152:24480"
+ },
+ "apiKey": {
+ "type": "password",
+ "label": "API key (only if PSK auth is enabled in ProdCom)",
+ "default": ""
+ }
+ },
+ "instanceSchema": {
+ "channel": {
+ "type": "string",
+ "label": "Channel filter (channel name; empty = all)",
+ "default": ""
+ },
+ "textSize": {
+ "type": "number",
+ "label": "Text size (px)",
+ "default": 15
+ },
+ "showTimes": {
+ "type": "boolean",
+ "label": "Show timestamps",
+ "default": false
+ }
+ }
+}
diff --git a/modules/prodcom-transcript/server.js b/modules/prodcom-transcript/server.js
new file mode 100644
index 0000000..13fa59f
--- /dev/null
+++ b/modules/prodcom-transcript/server.js
@@ -0,0 +1,93 @@
+'use strict';
+
+/**
+ * ProdCom Transcript — server part.
+ *
+ * One wildcard route that proxies /prodcom/* (including the SSE transcript
+ * stream) to the ProdCom API configured in the admin page, so browsers never
+ * talk to ProdCom directly (no CORS, and the API key never reaches clients).
+ * Ported from prodcom-listener/server.js.
+ */
+
+const http = require('http');
+
+// Hop-by-hop headers that must not be forwarded
+const HOP_HEADERS = new Set([
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
+]);
+
+/** Health shown in the admin page: outcome of the most recent proxied request. */
+let health = { status: 'connecting', message: 'No traffic yet' };
+
+function proxy(req, res, target, apiKey) {
+ const upstreamPath = (req.wildcard || '/') + (req.search || '');
+ const headers = {};
+ for (const [k, v] of Object.entries(req.headers)) {
+ if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
+ }
+ headers.host = target.host;
+ delete headers['accept-encoding']; // keep SSE unbuffered and simple
+ if (apiKey) headers.authorization = 'Bearer ' + apiKey;
+
+ const preq = http.request(
+ {
+ hostname: target.hostname,
+ port: target.port || 80,
+ path: upstreamPath,
+ method: req.method,
+ headers,
+ },
+ (pres) => {
+ health = { status: 'ok', message: `ProdCom reachable (last: HTTP ${pres.statusCode})` };
+ const outHeaders = {};
+ for (const [k, v] of Object.entries(pres.headers)) {
+ if (!HOP_HEADERS.has(k.toLowerCase())) outHeaders[k] = v;
+ }
+ res.writeHead(pres.statusCode, outHeaders);
+ // Flush immediately — SSE streams may send no bytes for a while, and
+ // the browser needs the headers to consider the stream open.
+ res.flushHeaders();
+ pres.pipe(res);
+ // If ProdCom dies mid-stream, pipe() unpipes on error without ending
+ // the browser's response, leaving SSE clients hanging on a dead
+ // stream. Destroy it so they notice immediately and reconnect.
+ pres.on('close', () => {
+ if (!res.writableEnded) res.destroy();
+ });
+ }
+ );
+
+ preq.on('error', (e) => {
+ health = { status: 'error', message: 'ProdCom unreachable: ' + (e.code || String(e)) };
+ if (!res.headersSent) {
+ res.writeHead(502, { 'Content-Type': 'application/json' });
+ }
+ res.end(JSON.stringify({ error: 'ProdCom unreachable', detail: e.code || String(e) }));
+ });
+
+ // Tear down the upstream connection when the browser disconnects
+ res.on('close', () => preq.destroy());
+ req.pipe(preq);
+}
+
+module.exports = {
+ init({ config }) {
+ health = config.prodcomUrl
+ ? { status: 'connecting', message: 'No traffic yet' }
+ : { status: 'error', message: 'No ProdCom URL configured' };
+ return {
+ stop() { /* nothing persistent to tear down — streams die with their sockets */ },
+ health: () => health,
+ };
+ },
+
+ routes({ config, log }) {
+ // An invalid URL throws here, which surfaces as a mount error in /admin.
+ const target = new URL(config.prodcomUrl || 'http://10.3.11.152:24480');
+ log(`proxying /prodcom/* → ${target.origin}`);
+ return {
+ '* /prodcom/*': (req, res) => proxy(req, res, target, config.apiKey || ''),
+ };
+ },
+};
diff --git a/modules/prodcom-transcript/style.css b/modules/prodcom-transcript/style.css
new file mode 100644
index 0000000..93dfd9e
--- /dev/null
+++ b/modules/prodcom-transcript/style.css
@@ -0,0 +1,194 @@
+/* ProdCom Transcript module — scoped to its tiles, shell variables only.
+ Entry styling carried over from prodcom-listener/public/style.css. */
+
+[data-module="prodcom-transcript"] .pt-wrap {
+ --pt-size: 15px;
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── toolbar ── */
+
+[data-module="prodcom-transcript"] .pt-bar {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+[data-module="prodcom-transcript"] .pt-spacer { flex: 1; }
+
+[data-module="prodcom-transcript"] .pt-btn {
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 3px 8px;
+ font-size: 11px;
+ font-family: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+}
+[data-module="prodcom-transcript"] .pt-btn:hover { border-color: var(--muted); }
+
+[data-module="prodcom-transcript"] .pt-dd { position: relative; }
+
+[data-module="prodcom-transcript"] .pt-menu {
+ position: absolute;
+ top: calc(100% + 5px);
+ left: 0;
+ z-index: 30;
+ min-width: 170px;
+ max-height: 45vh;
+ overflow-y: auto;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 5px;
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
+}
+[data-module="prodcom-transcript"] .pt-menu[hidden] { display: none; }
+
+[data-module="prodcom-transcript"] .pt-channel-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 9px;
+ background: none;
+ border: none;
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 12px;
+ font-family: inherit;
+ text-align: left;
+ cursor: pointer;
+ white-space: nowrap;
+}
+[data-module="prodcom-transcript"] .pt-channel-item:hover { background: var(--border); }
+
+[data-module="prodcom-transcript"] .pt-channel-item .check {
+ width: 14px;
+ flex: none;
+ color: var(--accent);
+ font-weight: 700;
+ visibility: hidden;
+}
+[data-module="prodcom-transcript"] .pt-channel-item.on .check { visibility: visible; }
+[data-module="prodcom-transcript"] .pt-channel-item .name { font-weight: 600; }
+
+[data-module="prodcom-transcript"] .pt-menu-divider {
+ height: 1px;
+ background: var(--border);
+ margin: 5px 4px;
+}
+
+/* ── stream ── */
+
+[data-module="prodcom-transcript"] .pt-stream {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 8px 10px 12px;
+ overscroll-behavior: contain;
+}
+
+[data-module="prodcom-transcript"] .pt-empty {
+ color: var(--muted);
+ font-size: 13px;
+ text-align: center;
+ padding-top: 14%;
+}
+[data-module="prodcom-transcript"] .pt-empty[hidden] { display: none; }
+
+[data-module="prodcom-transcript"] .pt-entry {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 5px 6px;
+ border-radius: 8px;
+ font-size: var(--pt-size);
+ line-height: 1.45;
+ animation: pt-fade-in 0.25s ease-out;
+}
+[data-module="prodcom-transcript"] .pt-entry:hover { background: var(--bg); }
+
+@keyframes pt-fade-in {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: none; }
+}
+
+[data-module="prodcom-transcript"] .pt-entry .time {
+ display: none;
+ color: var(--muted);
+ font-size: 0.72em;
+ font-variant-numeric: tabular-nums;
+ flex: none;
+ padding-top: 3px;
+}
+[data-module="prodcom-transcript"] .show-times .pt-entry .time { display: block; }
+
+[data-module="prodcom-transcript"] .pt-entry .avatar {
+ flex: none;
+ width: calc(var(--pt-size) * 1.9);
+ height: calc(var(--pt-size) * 1.9);
+ border-radius: 26%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ /* ~17% alpha tint of the channel color */
+ 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 .body { flex: 1; min-width: 0; }
+
+[data-module="prodcom-transcript"] .pt-entry .channel {
+ display: block;
+ font-size: 0.82em;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ line-height: 1;
+ white-space: nowrap;
+ margin-bottom: 4px;
+}
+
+[data-module="prodcom-transcript"] .pt-entry .text {
+ display: block;
+ overflow-wrap: break-word;
+}
+
+[data-module="prodcom-transcript"] .pt-entry.in-progress .text { color: var(--muted); }
+[data-module="prodcom-transcript"] .pt-entry.in-progress .text::after {
+ content: "▍";
+ color: var(--ch, var(--accent));
+ animation: pt-blink 1s steps(2) infinite;
+}
+@keyframes pt-blink { 50% { opacity: 0; } }
+
+[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; }
+
+/* ── jump-to-latest ── */
+
+[data-module="prodcom-transcript"] .pt-jump {
+ position: absolute;
+ bottom: 12px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 6;
+ background: var(--accent);
+ color: var(--bg);
+ font-weight: 600;
+ font-size: 12px;
+ font-family: inherit;
+ border: none;
+ border-radius: 999px;
+ padding: 7px 14px;
+ cursor: pointer;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
+}
+[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; }
diff --git a/tools/prodcom-mock.js b/tools/prodcom-mock.js
new file mode 100644
index 0000000..04df8b8
--- /dev/null
+++ b/tools/prodcom-mock.js
@@ -0,0 +1,131 @@
+/**
+ * Mock ProdCom API for verifying the prodcom-transcript module without a
+ * live ProdCom. Serves the endpoints the module uses — channels, groups,
+ * transcript history and the SSE stream — and simulates live speech:
+ * entries appear in progress, grow word by word (same id, in-place update),
+ * then complete.
+ *
+ * node tools/prodcom-mock.js [port] (default 24480)
+ *
+ * Point the module's admin config at http://127.0.0.1:. Kill and
+ * restart this process while tiles are open to watch them degrade and
+ * recover on their own.
+ */
+'use strict';
+
+const http = require('http');
+
+const PORT = Number.parseInt(process.argv[2] || '24480', 10);
+const STEP_MS = Number.parseInt(process.env.PRODCOM_STEP_MS || '700', 10);
+
+const CHANNELS = [
+ { id: 'ch-stage', name: 'Stage', color: '#e0645c' },
+ { id: 'ch-foh', name: 'FOH', color: '#5c9de0' },
+ { id: 'ch-video', name: 'Video', color: '#5cd08c' },
+ { id: 'ch-light', name: 'Lighting', color: '#e0b45c' },
+];
+
+const GROUPS = [
+ { id: 'g-band', name: 'Band', channelIds: ['ch-stage', 'ch-foh'] },
+ { id: 'g-tech', name: 'Tech', channelIds: ['ch-video', 'ch-light'] },
+];
+
+const PHRASES = [
+ 'Standby for the walk-in loop.',
+ 'Vocals are hot on channel two, trim it down a touch please.',
+ 'Camera three, give me a slow push on the worship leader.',
+ 'House to half, stage wash up for the next song.',
+ 'That was the last chorus, transition cue is next.',
+ 'Confidence monitor is frozen, refreshing it now.',
+ 'Great job everyone, that transition was clean.',
+ 'Pastor is walking up in thirty seconds.',
+];
+
+let history = []; // completed + in-progress entries, oldest first
+// Unique across mock restarts, so a reconnecting client's dedupe-by-id
+// treats post-restart entries as new (like real ProdCom ids would be).
+const RUN = Date.now().toString(36);
+let nextId = 1;
+const streams = new Set();
+
+/** The one in-progress utterance, or null. */
+let speaking = null; // { entry, words, at }
+
+function broadcast(entry) {
+ const frame = 'data: ' + JSON.stringify({ type: entry.inProgress ? 'transcript.update' : 'transcript.completed', entry }) + '\n\n';
+ for (const res of streams) {
+ try { res.write(frame); } catch { streams.delete(res); }
+ }
+}
+
+function stepSimulation() {
+ if (!speaking) {
+ const channel = CHANNELS[Math.floor(Math.random() * CHANNELS.length)];
+ const words = PHRASES[Math.floor(Math.random() * PHRASES.length)].split(' ');
+ const entry = {
+ id: 'e' + RUN + '-' + nextId++,
+ channelId: channel.id,
+ channelName: channel.name,
+ text: words[0],
+ date: new Date().toISOString(),
+ inProgress: true,
+ };
+ speaking = { entry, words, at: 1 };
+ history.push(entry);
+ if (history.length > 200) history.shift();
+ broadcast(entry);
+ return;
+ }
+ const { entry, words } = speaking;
+ if (speaking.at < words.length) {
+ entry.text = words.slice(0, ++speaking.at).join(' ');
+ broadcast(entry);
+ } else {
+ entry.inProgress = false;
+ broadcast(entry);
+ console.log(`[prodcom-mock] ${entry.channelName}: ${entry.text}`);
+ speaking = null;
+ }
+}
+
+setInterval(stepSimulation, STEP_MS);
+
+function sendJson(res, body) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(body));
+}
+
+const server = http.createServer((req, res) => {
+ const url = new URL(req.url, `http://x.invalid`);
+ const p = url.pathname;
+
+ if (p === '/api/v1/channels') return sendJson(res, { data: CHANNELS });
+ if (p === '/api/v1/groups') return sendJson(res, { data: GROUPS });
+ if (p === '/api/v1/transcript') {
+ const limit = Number.parseInt(url.searchParams.get('limit') || '100', 10) || 100;
+ return sendJson(res, { data: history.slice(-limit) });
+ }
+ if (p === '/api/v1/transcript/stream') {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ 'Cache-Control': 'no-cache, no-transform',
+ Connection: 'keep-alive',
+ });
+ res.write('retry: 3000\n\n');
+ streams.add(res);
+ console.log(`[prodcom-mock] stream client connected (${streams.size})`);
+ res.on('close', () => {
+ streams.delete(res);
+ console.log(`[prodcom-mock] stream client left (${streams.size})`);
+ });
+ return;
+ }
+
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'not found' }));
+});
+
+server.requestTimeout = 0;
+server.listen(PORT, '127.0.0.1', () => {
+ console.log(`[prodcom-mock] ProdCom mock API on http://127.0.0.1:${PORT} (step ${STEP_MS}ms)`);
+});