diff --git a/modules/propresenter-looks/client.js b/modules/propresenter-looks/client.js
new file mode 100644
index 0000000..785337d
--- /dev/null
+++ b/modules/propresenter-looks/client.js
@@ -0,0 +1,192 @@
+/* ProPresenter Look — client part.
+
+ The server part polls ProPresenter and streams the live audience look
+ here over SSE; the tile only renders. The look's name fills the tile so
+ it reads from across the booth, flashes once when it changes, and stays
+ on screen dimmed — marked "last known" — while ProPresenter is
+ unreachable. */
+
+export default function create({ root, moduleApi }) {
+ let view = null;
+ let feedOffline = false;
+ let stream = null;
+ let resizeObserver = null;
+ let flashTimer = null;
+ let lastName = null;
+
+ root.innerHTML = `
+
`;
+
+ const screenEl = root.querySelector('.pl-screen');
+ const topEl = root.querySelector('.pl-top');
+ const headingEl = root.querySelector('.pl-heading');
+ const liveEl = root.querySelector('.pl-live');
+ const liveTextEl = root.querySelector('.pl-live-text');
+ const nameBox = root.querySelector('.pl-name-box');
+ const nameEl = root.querySelector('.pl-name');
+ const metaEl = root.querySelector('.pl-meta');
+ const tagEl = root.querySelector('.pl-tag');
+ const sinceEl = root.querySelector('.pl-since');
+
+ const on = (key) => moduleApi.instanceSettings[key] !== false;
+
+ function timeText(ms) {
+ return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+ }
+
+ /** Biggest font size at which the name fits its box without breaking a word. */
+ function fitName() {
+ const MIN = 11;
+ const boxHeight = nameBox.clientHeight;
+ const boxWidth = nameBox.clientWidth;
+ nameEl.style.overflowWrap = 'normal';
+ if (boxHeight < MIN || !boxWidth) {
+ nameEl.style.fontSize = `${MIN}px`;
+ return;
+ }
+ const fits = (size) => {
+ nameEl.style.fontSize = `${size}px`;
+ return nameEl.scrollWidth <= nameBox.clientWidth + 1 && nameEl.scrollHeight <= nameBox.clientHeight + 1;
+ };
+ let lo = MIN;
+ let hi = Math.max(MIN, Math.floor(boxHeight));
+ let best = MIN;
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1;
+ if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
+ }
+ nameEl.style.fontSize = `${best}px`;
+ // One word too long even at MIN: let it break rather than overflow.
+ if (best === MIN && nameEl.scrollWidth > nameBox.clientWidth + 1) nameEl.style.overflowWrap = 'anywhere';
+ }
+
+ function flash() {
+ screenEl.classList.remove('is-changed');
+ void screenEl.offsetWidth; // restart the animation
+ screenEl.classList.add('is-changed');
+ clearTimeout(flashTimer);
+ flashTimer = setTimeout(() => screenEl.classList.remove('is-changed'), 2600);
+ }
+
+ function setStatus() {
+ if (feedOffline) {
+ moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
+ } else if (!view) {
+ moduleApi.setStatus('connecting', 'Connecting…');
+ } else if (!view.configured) {
+ moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
+ } else if (view.reachable) {
+ moduleApi.setStatus('ok', `Live look: ${view.look ? view.look.name : '—'}`);
+ } else if (!view.updatedAt && !view.lastError) {
+ moduleApi.setStatus('connecting', 'Reaching ProPresenter…');
+ } else {
+ moduleApi.setStatus('error', `ProPresenter unreachable${view.lastError ? ` (${view.lastError})` : ''} — reconnecting…`);
+ }
+ }
+
+ function render() {
+ setStatus();
+ const offline = feedOffline || Boolean(view && view.configured && !view.reachable && (view.updatedAt || view.lastError));
+ const look = view && view.look;
+
+ screenEl.classList.toggle('is-offline', offline);
+ headingEl.hidden = !on('showHeading');
+ liveEl.hidden = !on('showLive') && !offline; // losing the feed always shows
+ topEl.hidden = headingEl.hidden && liveEl.hidden;
+
+ liveEl.classList.toggle('is-live', !offline && Boolean(view && view.reachable));
+ liveEl.classList.toggle('is-down', offline);
+ if (feedOffline) liveTextEl.textContent = 'ProdDash offline';
+ else if (offline) liveTextEl.textContent = look ? 'Unreachable · last known' : 'Unreachable';
+ else if (view && view.reachable) liveTextEl.textContent = 'Live';
+ else liveTextEl.textContent = 'Connecting…';
+
+ let name;
+ let waiting = false;
+ if (look) {
+ name = look.name;
+ } else if (view && !view.configured) {
+ name = 'Set up in Admin';
+ waiting = true;
+ } else if (offline) {
+ name = 'ProPresenter unreachable';
+ waiting = true;
+ } else {
+ name = 'Connecting…';
+ waiting = true;
+ }
+ nameEl.textContent = name;
+ nameEl.classList.toggle('is-waiting', waiting);
+
+ if (look && lastName !== null && lastName !== `${look.name}|${look.modified}`) flash();
+ if (look) lastName = `${look.name}|${look.modified}`;
+
+ tagEl.hidden = !(look && look.modified);
+ const since = look && view.changedAt && on('showSince');
+ sinceEl.hidden = !since;
+ if (since) sinceEl.textContent = `since ${timeText(view.changedAt)}`;
+ metaEl.hidden = tagEl.hidden && sinceEl.hidden;
+
+ fitName();
+ }
+
+ function connect() {
+ stream = moduleApi.sse('/stream', {
+ open() {
+ feedOffline = false;
+ render();
+ },
+ error() {
+ feedOffline = true;
+ render();
+ },
+ events: {
+ state(e) {
+ try {
+ view = JSON.parse(e.data);
+ } catch {
+ return;
+ }
+ feedOffline = false;
+ render();
+ },
+ },
+ });
+ }
+
+ return {
+ start() {
+ render();
+ moduleApi.fetch('/state')
+ .then((r) => (r.ok ? r.json() : null))
+ .then((body) => {
+ if (body && body.state && !view) {
+ view = body.state;
+ render();
+ }
+ })
+ .catch(() => {});
+ connect();
+ resizeObserver = new ResizeObserver(() => fitName());
+ resizeObserver.observe(nameBox);
+ },
+ stop() {
+ if (stream) stream.close();
+ stream = null;
+ if (resizeObserver) resizeObserver.disconnect();
+ resizeObserver = null;
+ clearTimeout(flashTimer);
+ root.innerHTML = '';
+ },
+ };
+}