propresenter-now-next module: Now/Next tile fed by propresenter-core

Milestone 4. propresenter-core copied in unchanged (single home for every
ProPresenter 7/20 quirk). Server part owns the one polling connection and
exposes GET /state plus GET /stream (SSE, change-detected, heartbeated);
health reported for the admin page. Client ports the wall display's
rendering: fill-the-box title fit that never breaks words, tidied titles,
slide meta + progress bar, NEXT list with hidden-row '+ N more' overflow,
playlist name and live chip, all scaled to the tile via a JS-set --unit.
Verified against pp-mock.js including the arrangement-repeat case (the
counter follows core's honest-live-index rule, never X > Y) and
kill-and-restart recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNYUv9EyrGSFmCnmH7WYKH
This commit is contained in:
Claude
2026-08-30 15:09:46 +00:00
parent a549bb065a
commit 9298b4d142
6 changed files with 1973 additions and 0 deletions
+474
View File
@@ -0,0 +1,474 @@
/* ProPresenter Now / Next — client part.
Ported from band-lineup-display/propresenter-app/public/app.js into the
ProdDash module contract: the server part does all the ProPresenter
talking and streams the view-model here over SSE. Connection setup lives
in /admin; the tile only renders. */
export default function create({ root, moduleApi }) {
let state = null;
let feedOffline = false;
let stream = null;
let resizeObserver = null;
/* ── DOM skeleton ───────────────────────────────────────────────── */
root.innerHTML = `
<div class="nn-screen">
<header class="nn-top">
<div class="nn-playlist"></div>
<div class="nn-live"><span class="nn-live-dot"></span><span class="nn-live-text">Connecting…</span></div>
</header>
<section class="nn-now">
<div class="nn-label">Now</div>
<div class="nn-title-box"><div class="nn-title is-waiting">Connecting…</div></div>
<div class="nn-meta"></div>
<div class="nn-progress" hidden><div class="nn-progress-fill"></div></div>
</section>
<section class="nn-next-wrap">
<div class="nn-next-label" hidden>Next</div>
<div class="nn-next-list"></div>
<div class="nn-next-more" hidden></div>
</section>
</div>`;
const screenEl = root.querySelector('.nn-screen');
const topRow = root.querySelector('.nn-top');
const playlistNameEl = root.querySelector('.nn-playlist');
const liveEl = root.querySelector('.nn-live');
const liveTextEl = root.querySelector('.nn-live-text');
const nowEl = root.querySelector('.nn-now');
const nowTitleBox = root.querySelector('.nn-title-box');
let nowTitleEl = root.querySelector('.nn-title');
const nowMetaEl = root.querySelector('.nn-meta');
const progressEl = root.querySelector('.nn-progress');
const progressFillEl = root.querySelector('.nn-progress-fill');
const nextLabelEl = root.querySelector('.nn-next-label');
const nextListEl = root.querySelector('.nn-next-list');
const nextMoreEl = root.querySelector('.nn-next-more');
function prefs() {
return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles
}
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
function cleanItemName(raw) {
const original = String(raw || '').trim();
let s = original;
s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags
s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code
s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators
s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space
s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace
return s || original; // never blank out a title (e.g. a name that was only a tag)
}
function displayName(raw) {
return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim();
}
function itemTypeTag(type) {
switch (type) {
case 'media': return 'Media';
case 'audio': return 'Audio';
case 'livevideo': return 'Live';
case 'placeholder': return 'PCO';
default: return '';
}
}
/* ── rendering ──────────────────────────────────────────────────── */
/** The items after the live one, hidden entries dropped. */
function upcomingItems() {
const items = Array.isArray(state?.items) ? state.items : [];
// Presenting from the playlist → everything after the live item.
// Presenting from the Library (activeIndex < 0) → the whole list is still ahead.
const from = state && state.activeIndex >= 0 ? state.activeIndex + 1 : 0;
const out = [];
for (let i = from; i < items.length; i += 1) {
if (!items[i].isHidden) out.push(items[i]);
}
return out;
}
/**
* Hand the browser a brand-new title element whenever the text changes —
* on some GPUs a large text element keeps a stale compositor backing
* store, ghosting the previous song under the new one. A fresh node has
* no old backing store. (Carried over from the wall display.)
*/
let lastTitleText = null;
function setNowTitle(text) {
if (text === lastTitleText && nowTitleEl.isConnected) return;
lastTitleText = text;
const fresh = document.createElement('div');
fresh.className = 'nn-title';
fresh.textContent = text;
nowTitleEl.replaceWith(fresh);
nowTitleEl = fresh;
}
/**
* Size the NOW title as large as fits without ever breaking a word:
* with word-breaking forbidden, an over-wide word overflows and the same
* fit test that guards height rejects that size too; binary search lands
* on the biggest size where whole words fit. Hysteresis stops ±1px flap.
*/
function fitNowTitle() {
if (nowTitleEl.classList.contains('is-waiting')) return;
const boxHeight = nowTitleBox.clientHeight;
const boxWidth = nowTitleBox.clientWidth;
const MIN = 11;
nowTitleEl.style.whiteSpace = 'normal';
nowTitleEl.style.overflowWrap = 'normal';
nowTitleEl.style.wordBreak = 'normal';
nowTitleEl.style.hyphens = 'none';
if (boxHeight < MIN || !boxWidth) {
nowTitleEl.style.fontSize = `${MIN}px`;
return;
}
const fits = (size) => {
nowTitleEl.style.fontSize = `${size}px`;
return nowTitleEl.scrollWidth <= nowTitleBox.clientWidth + 1
&& nowTitleEl.scrollHeight <= nowTitleBox.clientHeight + 1;
};
const max = Math.max(MIN, Math.floor(boxHeight));
let best = MIN;
if (fits(max)) {
best = max;
} else {
let lo = MIN;
let hi = max;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
}
}
const currentSize = parseFloat(nowTitleEl.style.fontSize) || 0;
// Hold the current size only if it still genuinely fits (a plain ±1px
// band can straddle the line-wrap threshold and stick on the
// overflowing side).
if (currentSize && Math.abs(currentSize - best) <= 1 && fits(currentSize)) {
nowTitleEl.style.fontSize = `${currentSize}px`;
return;
}
nowTitleEl.style.fontSize = `${best}px`;
// Last resort: one word so long it can't fit even at MIN — allow it to
// break rather than overflow the card.
if (best === MIN && nowTitleEl.scrollWidth > nowTitleBox.clientWidth + 1) {
nowTitleEl.style.overflowWrap = 'anywhere';
nowTitleEl.style.wordBreak = 'break-word';
}
}
/**
* Hide the NEXT rows past the bottom of the list and count them into
* "+ N more". Rows are hidden, never removed, so the list's height stays
* fixed and the layout can't ratchet.
*/
function markNextOverflow() {
const rows = Array.from(nextListEl.children);
for (const row of rows) row.style.visibility = '';
let hidden = 0;
for (let pass = 0; pass < 3; pass += 1) {
const listRect = nextListEl.getBoundingClientRect();
if (listRect.height <= 0) return;
let changed = 0;
for (const row of rows) {
if (row.style.visibility === 'hidden') continue;
if (row.getBoundingClientRect().bottom > listRect.bottom + 1) {
row.style.visibility = 'hidden';
changed += 1;
}
}
hidden += changed;
if (hidden > 0) {
nextMoreEl.hidden = false;
nextMoreEl.textContent = `+ ${hidden} more`;
}
if (changed === 0) break;
}
if (hidden === 0) {
nextMoreEl.hidden = true;
nextMoreEl.textContent = '';
}
}
function renderLive() {
liveEl.classList.remove('is-live', 'is-down');
if (feedOffline) {
liveEl.classList.add('is-down');
liveTextEl.textContent = 'Feed offline';
moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
return;
}
if (!state) {
liveTextEl.textContent = 'Connecting…';
moduleApi.setStatus('connecting', 'Connecting…');
return;
}
if (!state.enabled) {
liveTextEl.textContent = 'Not set up';
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
return;
}
if (state.reachable) {
liveEl.classList.add('is-live');
liveTextEl.textContent = 'Live';
moduleApi.setStatus('ok', 'Following ProPresenter');
return;
}
liveEl.classList.add('is-down');
liveTextEl.textContent = 'Reconnecting…';
moduleApi.setStatus('error', 'ProPresenter unreachable — reconnecting…');
}
function renderNow() {
const activeItem = state && state.activeIndex >= 0 ? state.items[state.activeIndex] : null;
const rawName = activeItem?.name || state?.activePresentationName || '';
for (const stale of nowEl.querySelectorAll('.nn-hint, .nn-stale')) stale.remove();
if (feedOffline) {
setNowTitle(rawName ? `Last shown: ${displayName(rawName)}` : 'Feed offline');
nowTitleEl.classList.add('is-waiting');
nowTitleEl.style.fontSize = '';
nowMetaEl.textContent = '';
progressEl.hidden = true;
const staleEl = document.createElement('div');
staleEl.className = 'nn-stale';
staleEl.textContent = 'Not receiving updates from the ProdDash server.';
nowEl.appendChild(staleEl);
return;
}
if (!rawName) {
// A status message is not a song title — keep its CSS size instead of
// the fill-the-box fit.
let hint = '';
if (!state?.enabled) {
setNowTitle('No ProPresenter connection');
hint = 'Set the ProPresenter host and port in Admin.';
} else {
// Connected with nothing live is normal (output cleared) — say it
// plainly so nobody goes hunting for a broken connection.
setNowTitle(state?.reachable ? 'Nothing on screen' : 'Reconnecting…');
hint = state?.reachable
? 'Connected to ProPresenter — waiting for a slide to go live.'
: 'Trying to reach ProPresenter.';
}
nowTitleEl.classList.add('is-waiting');
nowTitleEl.style.fontSize = '';
nowMetaEl.textContent = '';
progressEl.hidden = true;
if (hint) {
const hintEl = document.createElement('div');
hintEl.className = 'nn-hint';
hintEl.textContent = hint;
nowEl.appendChild(hintEl);
}
return;
}
setNowTitle(displayName(rawName));
nowTitleEl.classList.remove('is-waiting');
fitNowTitle();
// Re-fit after layout settles: on the first render the box may not have
// its final height, and a too-tall fit would overflow for a frame.
requestAnimationFrame(() => {
if (!nowTitleEl.classList.contains('is-waiting')) fitNowTitle();
});
// An empty NEXT list looks broken unless the tile says why it is empty.
if (!upcomingItems().length) {
const kind = state?.playlistKind;
let why = '';
if (kind === 'group' || kind === 'none') {
why = 'Presenting from the library — no playlist open.';
} else if (kind === 'unknown') {
why = 'ProPresenter did not return this playlist’s items.';
} else if (Array.isArray(state?.items) && state.items.length) {
why = 'Last item in the playlist.';
}
if (why) {
const note = document.createElement('div');
note.className = 'nn-hint';
note.textContent = why;
nowEl.appendChild(note);
}
}
const total = state.currentSlideTotal || activeItem?.slideCount || 0;
const current = state.currentSlide || 0;
if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current);
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) {
nowMetaEl.textContent = `${total} slides`;
} else if (current > 0) {
nowMetaEl.textContent = `Slide ${current}`;
} else {
nowMetaEl.textContent = '';
}
if (prefs().showProgress && total > 0) {
progressEl.hidden = false;
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
progressFillEl.style.width = `${pct}%`;
} else {
progressEl.hidden = true;
}
}
function renderNext() {
nextListEl.innerHTML = '';
const upcoming = upcomingItems();
screenEl.classList.toggle('no-upcoming', upcoming.length === 0);
for (const item of upcoming) {
const row = document.createElement('div');
const nameEl = document.createElement('div');
nameEl.className = 'nn-next-name';
if (item.type === 'header') {
row.className = 'nn-next-item is-header';
nameEl.textContent = displayName(item.name || '');
row.appendChild(nameEl);
nextListEl.appendChild(row);
continue;
}
row.className = 'nn-next-item';
nameEl.textContent = displayName(item.name) || '(untitled)';
row.appendChild(nameEl);
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div');
chip.className = 'nn-count-chip';
chip.textContent = String(item.slideCount);
row.appendChild(chip);
} else {
const tag = itemTypeTag(item.type);
if (tag) {
const tagEl = document.createElement('div');
tagEl.className = 'nn-type-tag';
tagEl.textContent = tag;
row.appendChild(tagEl);
}
}
nextListEl.appendChild(row);
}
nextLabelEl.hidden = upcoming.length === 0;
nextMoreEl.hidden = true;
markNextOverflow();
requestAnimationFrame(markNextOverflow);
}
function render() {
screenEl.classList.toggle('is-offline', feedOffline);
const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled;
topRow.classList.toggle('hidden-row', !showTop);
playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : '';
renderLive();
renderNow();
renderNext();
}
/* ── sizing: --unit scales the whole card to the tile ───────────── */
function applyUnit() {
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
}
/* ── live feed ──────────────────────────────────────────────────── */
function connect() {
stream = moduleApi.sse('/stream', {
open() {
feedOffline = false;
render();
},
error() {
// EventSource retries by itself (and the shell recreates closed
// streams), but the tile must admit it is frozen meanwhile.
feedOffline = true;
render();
},
events: {
state(ev) {
try {
state = JSON.parse(ev.data);
} catch {
return;
}
feedOffline = false;
render();
},
},
});
}
/* ── lifecycle ──────────────────────────────────────────────────── */
return {
start() {
applyUnit();
moduleApi.setStatus('connecting', 'Connecting…');
// Instant paint from the snapshot, then live updates.
moduleApi.fetch('/state')
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (body && body.state && !state) {
state = body.state;
render();
}
})
.catch(() => { /* the stream will cover it */ });
connect();
// Re-fit on any size change; guard against reacting to our own DOM
// writes by only re-rendering when a watched box actually changed.
let lastW = 0;
let lastH = 0;
let pending = false;
resizeObserver = new ResizeObserver(() => {
const w = Math.round(root.clientWidth);
const h = Math.round(root.clientHeight);
if (w === lastW && h === lastH) return;
lastW = w;
lastH = h;
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
applyUnit();
fitNowTitle();
markNextOverflow();
});
});
resizeObserver.observe(root);
render();
},
stop() {
stream?.close();
stream = null;
resizeObserver?.disconnect();
resizeObserver = null;
root.innerHTML = '';
},
onResize() {
applyUnit();
fitNowTitle();
markNextOverflow();
},
};
}
+33
View File
@@ -0,0 +1,33 @@
{
"id": "propresenter-now-next",
"name": "ProPresenter Now / Next",
"version": "1.0.0",
"description": "What is live in ProPresenter and what comes next",
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 3, "h": 3 },
"defaultSize": { "w": 4, "h": 5 },
"configSchema": {
"host": {
"type": "string",
"label": "ProPresenter host / IP",
"default": ""
},
"port": {
"type": "number",
"label": "ProPresenter network port",
"default": 1025
},
"password": {
"type": "password",
"label": "API password (only if set in ProPresenter)",
"default": ""
}
},
"instanceSchema": {
"showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true },
"showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true },
"cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
{
"name": "propresenter-core",
"version": "1.0.0",
"private": true,
"description": "Shared ProPresenter 7/20 network-API polling client (no dependencies, Node built-ins only)",
"main": "index.js",
"license": "MIT"
}
+132
View File
@@ -0,0 +1,132 @@
'use strict';
/**
* ProPresenter Now / Next — server part.
*
* Owns the one ProPresenter connection (via propresenter-core, copied here
* unchanged — it holds every ProPresenter 7/20 quirk) and fans the live
* view-model out to every tile over SSE. Browsers never talk to
* ProPresenter directly.
*
* GET /state → { state: <view-model> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
*/
const { createProPresenterClient, clearedViewModel } = require('./propresenter-core');
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null;
module.exports = {
init({ config, log }) {
const streams = new Set();
const enabled = Boolean(config.host && Number(config.port));
let latest = clearedViewModel();
let lastFrame = '';
const client = createProPresenterClient({
onData(view) {
latest = view;
if (!streams.size) return;
let frame;
try {
frame = JSON.stringify(view);
} catch {
return;
}
// The poll loop emits every tick whether anything changed or not.
// Broadcasting only real changes keeps idle tiles genuinely idle.
if (frame === lastFrame) return;
lastFrame = frame;
const payload = `event: state\ndata: ${frame}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
},
});
client.configure({
enabled,
host: String(config.host || ''),
port: Number(config.port) || 1025,
password: String(config.password || ''),
});
if (enabled) {
client.start();
log(`following ProPresenter at ${config.host}:${config.port}`);
} else {
log('no ProPresenter host configured — set one in /admin');
}
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = { streams, getLatest: () => latest };
return {
stop() {
clearInterval(heartbeat);
client.stop();
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current && current.streams === streams) current = null;
},
health() {
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
if (latest.reachable) return { status: 'ok', message: `Following ${config.host}:${config.port}` };
return {
status: 'error',
message: latest.lastError
? `ProPresenter unreachable: ${latest.lastError}`
: 'Trying to reach ProPresenter…',
};
},
};
},
routes() {
return {
'GET /state': (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ state: current ? current.getLatest() : clearedViewModel() }));
},
'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');
const view = current ? current.getLatest() : clearedViewModel();
res.write(`event: state\ndata: ${JSON.stringify(view)}\n\n`);
if (!current) return void res.end();
const { streams } = current;
// A viewer that stops reading (locked tablet, sleeping laptop) must
// not queue frames forever; keepalive probes make the OS notice.
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
streams.add(res);
req.on('close', () => streams.delete(res));
},
};
},
};
+297
View File
@@ -0,0 +1,297 @@
/* ProPresenter Now / Next module — the wall display's visual language,
scaled to a tile. All sizes hang off --unit (set from the tile height by
client.js); all colors come from the shell's variables. */
[data-module="propresenter-now-next"] .nn-screen {
--unit: 12px;
--nn-pad: calc(var(--unit) * 1.1);
--nn-gap: calc(var(--unit) * 0.7);
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: var(--nn-gap);
padding: var(--nn-pad);
user-select: none;
-webkit-user-select: none;
}
/* With no NEXT list there is nothing below the card — centre it. */
[data-module="propresenter-now-next"] .nn-screen.no-upcoming {
justify-content: center;
}
/* ── top row ── */
[data-module="propresenter-now-next"] .nn-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--nn-gap);
flex-shrink: 0;
min-height: 0;
}
[data-module="propresenter-now-next"] .nn-top.hidden-row { display: none; }
/* Losing the feed is the one thing the tile MUST admit to — overrides the
"hide the top row" preference. */
[data-module="propresenter-now-next"] .is-offline .nn-top,
[data-module="propresenter-now-next"] .nn-screen.is-offline .nn-top {
display: flex !important;
}
[data-module="propresenter-now-next"] .is-offline .nn-live-text {
opacity: 1 !important;
color: var(--danger);
}
[data-module="propresenter-now-next"] .is-offline .nn-now {
border-color: color-mix(in srgb, var(--danger) 50%, transparent);
}
[data-module="propresenter-now-next"] .nn-playlist {
flex: 1;
min-width: 0;
font-size: calc(var(--unit) * 1.0);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="propresenter-now-next"] .nn-live {
display: flex;
align-items: center;
gap: calc(var(--unit) * 0.45);
flex-shrink: 0;
font-size: calc(var(--unit) * 0.9);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-now-next"] .nn-live-dot {
width: calc(var(--unit) * 0.6);
height: calc(var(--unit) * 0.6);
border-radius: 999px;
background: var(--border);
transition: background 0.3s ease, box-shadow 0.3s ease;
}
[data-module="propresenter-now-next"] .nn-live.is-live .nn-live-dot {
background: var(--accent);
box-shadow: 0 0 0 calc(var(--unit) * 0.2) color-mix(in srgb, var(--accent) 18%, transparent);
}
[data-module="propresenter-now-next"] .nn-live.is-down .nn-live-dot {
background: var(--danger);
box-shadow: 0 0 0 calc(var(--unit) * 0.2) color-mix(in srgb, var(--danger) 16%, transparent);
}
/* Idle tiles don't need a status chip staring at them — it fades once live. */
[data-module="propresenter-now-next"] .nn-live.is-live .nn-live-text {
opacity: 0;
transition: opacity 0.6s ease 1.4s;
}
/* ── NOW card ──
FIXED height so the title box is deterministic and the NOW/NEXT layout
cannot oscillate (see the wall display's notes). */
[data-module="propresenter-now-next"] .nn-now {
flex: 0 0 auto;
height: 42%;
min-height: calc(var(--unit) * 7);
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: calc(var(--unit) * 0.9);
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 9%, transparent),
color-mix(in srgb, var(--bg) 60%, transparent)
);
padding: calc(var(--nn-pad) * 0.8) var(--nn-pad);
}
[data-module="propresenter-now-next"] .nn-label {
flex-shrink: 0;
font-size: calc(var(--unit) * 0.9);
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
[data-module="propresenter-now-next"] .nn-title-box {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
overflow: hidden;
margin-top: calc(var(--unit) * 0.3);
}
[data-module="propresenter-now-next"] .nn-title {
width: 100%;
/* Font size is set by fitNowTitle() so the title fills the box; keep
line-height ≥ the font's natural ~1.2 or large ascenders/descenders clip. */
font-size: calc(var(--unit) * 2.4);
font-weight: 700;
line-height: 1.2;
color: var(--text);
/* Wrapping is controlled by fitNowTitle(), which forbids mid-word breaks. */
overflow-wrap: normal;
word-break: normal;
}
/* Status text, not a title: readable size, never the fill-the-box treatment. */
[data-module="propresenter-now-next"] .nn-title.is-waiting {
color: var(--muted);
font-weight: 600;
font-size: calc(var(--unit) * 1.5);
white-space: normal;
}
[data-module="propresenter-now-next"] .nn-hint {
flex-shrink: 0;
margin-top: calc(var(--unit) * 0.4);
font-size: calc(var(--unit) * 0.95);
line-height: 1.4;
color: var(--muted);
}
[data-module="propresenter-now-next"] .nn-stale {
flex-shrink: 0;
margin-top: calc(var(--unit) * 0.4);
font-size: calc(var(--unit) * 0.95);
font-weight: 600;
color: var(--danger);
}
[data-module="propresenter-now-next"] .nn-meta {
flex-shrink: 0;
margin-top: calc(var(--unit) * 0.45);
font-size: calc(var(--unit) * 1.15);
font-weight: 600;
color: var(--text);
opacity: 0.85;
font-variant-numeric: tabular-nums;
/* Never wrap: a wrap changes the title box height, which refits the title. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="propresenter-now-next"] .nn-meta:empty { display: none; }
[data-module="propresenter-now-next"] .nn-progress {
flex-shrink: 0;
margin-top: calc(var(--unit) * 0.55);
height: calc(var(--unit) * 0.4);
border-radius: 999px;
background: color-mix(in srgb, var(--text) 10%, transparent);
overflow: hidden;
}
[data-module="propresenter-now-next"] .nn-progress[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-progress-fill {
height: 100%;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 65%, var(--panel));
transition: width 0.25s ease;
}
/* ── NEXT list ── */
[data-module="propresenter-now-next"] .nn-next-wrap {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: calc(var(--unit) * 0.5);
}
[data-module="propresenter-now-next"] .nn-next-label {
flex-shrink: 0;
font-size: calc(var(--unit) * 0.9);
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-now-next"] .nn-next-label[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-next-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: calc(var(--unit) * 0.45);
overflow: hidden;
}
[data-module="propresenter-now-next"] .nn-next-item {
display: flex;
align-items: center;
gap: var(--nn-gap);
padding: calc(var(--unit) * 0.6) calc(var(--unit) * 0.85);
border: 1px solid var(--border);
border-radius: calc(var(--unit) * 0.6);
background: color-mix(in srgb, var(--bg) 70%, transparent);
}
[data-module="propresenter-now-next"] .nn-next-name {
flex: 1;
min-width: 0;
font-size: calc(var(--unit) * 1.25);
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="propresenter-now-next"] .nn-count-chip {
flex-shrink: 0;
min-width: calc(var(--unit) * 2);
padding: calc(var(--unit) * 0.15) calc(var(--unit) * 0.5);
text-align: center;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--text);
font-size: calc(var(--unit) * 0.95);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
[data-module="propresenter-now-next"] .nn-type-tag {
flex-shrink: 0;
font-size: calc(var(--unit) * 0.8);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-now-next"] .nn-next-item.is-header {
background: none;
border: none;
border-bottom: 1px solid var(--border);
border-radius: 0;
padding: calc(var(--unit) * 0.55) calc(var(--unit) * 0.1) calc(var(--unit) * 0.2);
}
[data-module="propresenter-now-next"] .nn-next-item.is-header .nn-next-name {
font-size: calc(var(--unit) * 0.9);
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
[data-module="propresenter-now-next"] .nn-next-more {
flex-shrink: 0;
font-size: calc(var(--unit) * 0.9);
color: var(--muted);
font-weight: 600;
}
[data-module="propresenter-now-next"] .nn-next-more[hidden] { display: none; }