/* global React, ReactDOM */ // Bromont 2026 — On-Air Scorebug overlay. // // Consumed by OBS as a Browser Source at 1920×1080 with a transparent // background. State is broadcast from the timer app on the same origin // via BroadcastChannel("bromont.sj-timer.v1"). Two layouts are selectable // via query string: // ?layout=l3 lower-third bar (default) // ?layout=bug corner scorebug, top-left // // Optional URL params: // ?demo=1 — render a fake state without listening, for design QA // ?safe=0|1 — show 10% title-safe guides (1 = on; default off) const { useState, useEffect } = React; // Console-driven channel: the operator's console at /console/sj-timer/ is the // publisher (same machine / same origin). Nothing renders until the operator // puts the round on air (state.onAir === true). const BROADCAST_CHANNEL = "bromont.sj-timer.console.v1"; // -------------------------------------------------------------- // Helpers — identical math to app.jsx so the overlay looks correct // even before a state message arrives (we render zeros at idle). // -------------------------------------------------------------- function formatClock(ms) { const total = Math.max(0, ms || 0); const m = Math.floor(total / 60000); const s = Math.floor((total % 60000) / 1000); const t = Math.floor((total % 1000) / 100); return { m, s: String(s).padStart(2, "0"), t }; } function fmtFaults(n) { if (n == null) return "0"; if (Number.isInteger(n)) return String(n).padStart(2, "0"); return n.toFixed(1); } // -------------------------------------------------------------- // Default / idle state — also doubles as the demo payload. // -------------------------------------------------------------- const IDLE = { phase: "pre", rider: "Karl Slezak", horse: "Hot Bobo", country: "CAN", order: 14, totalOrder: 32, classLabel: "CCI4*-L", timeAllowed: 64, timeLimit: 128, elapsedMs: 0, adjustedElapsedMs: 0, rails: 0, refusals: 0, refusalLimit: 3, timeCorrection: 0, tFaults: 0, totalFaults: 0, overTime: false, nearLimit: false, eliminated: false, elimReason: null, }; // -------------------------------------------------------------- // Subscriber hook — listens on the BroadcastChannel and exposes // the latest state. Sends a "hello" message on mount so the timer // app replays its current snapshot (otherwise the overlay would // stay idle until the next state change in the timer). // -------------------------------------------------------------- function useScorebugState(demo) { const [state, setState] = useState(IDLE); useEffect(() => { if (demo) return; // freeze on the IDLE payload for design QA if (typeof BroadcastChannel === "undefined") return; const ch = new BroadcastChannel(BROADCAST_CHANNEL); ch.onmessage = (ev) => { if (ev?.data?.type === "state" && ev.data.payload) { setState(ev.data.payload); } }; ch.postMessage({ type: "hello" }); return () => ch.close(); }, [demo]); return state; } // -------------------------------------------------------------- // Status pill — LIVE / HELD / OVER / ELIM // -------------------------------------------------------------- function StatusPill({ state }) { let label = "STAND BY"; let cls = "neutral"; if (state.eliminated) { label = "ELIMINATED"; cls = "elim"; } else if (state.phase === "live" && state.overTime) { label = "OVER TIME"; cls = "warn"; } else if (state.phase === "live") { label = "ON COURSE"; cls = "live"; } else if (state.phase === "paused") { label = "HELD"; cls = "held"; } else if (state.phase === "done") { label = "FINISHED"; cls = "done"; } return (