Update interactive presentation and presenter console

This commit is contained in:
Codex
2026-09-20 19:43:33 +02:00
parent 348f4a9738
commit 5217c574e9
21 changed files with 1318 additions and 112 deletions
+64
View File
@@ -0,0 +1,64 @@
# Presenter console
Serve `presentation/` using the same local server as the deck:
```sh
python3 -m http.server 8000 --bind 127.0.0.1 --directory presentation
```
Open <http://localhost:8000/presenter/> in Chrome or another modern desktop browser.
Select **Open audience window**, move the new window to the HDMI display, then click
**Full screen** there. Keep the console on the Mac display. Use extended displays,
not mirroring. Both windows must use the same browser profile and origin.
The console provides interactive slide preview, notes with adjustable type size,
slide navigation, per-slide popup buttons, a manually started timer, and a choice
between notes beside or below the preview. Notes fill the available horizontal space.
Up next includes a live, non-interactive thumbnail of the following slide in both
layouts; at the last slide it is replaced by the end-of-presentation message.
With notes beside the preview, the current preview keeps the height calculated as
65% of the space available without popup controls. It resizes with the window,
not with the number of popups. The next preview takes the space left after the
current preview, navigation, popup buttons and labels, and never renders larger
than the current one.
Popup controls contain only numbered
buttons and disappear entirely on slides without popups. The current preview omits
the 34px slide footer; the audience slide remains unchanged.
Arrow keys change slides; Escape closes
the popup. Changing slide closes any open popup. Notes scroll independently and
return to the beginning when the slide changes.
Popup controls, clickable images and speaker-note headings open only on click
or explicit keyboard activation (Enter, Space or the numbered shortcuts).
Hover and keyboard focus may highlight a control but never open or switch a popup.
On slide 08,
the five numbered headings in the speaker notes also open the corresponding
Validation detail, centered on the slide with the shared lens effect. The fifth
detail explains all three advantages; on the slide, the advantages panel highlights
on hover and opens on click. A detail
stays open when the pointer leaves, until another detail is selected, Escape or
Close is used, or the slide changes. The preview and audience show the same detail.
Slide 09 has seven numbered screenshot controls, in the supplied tour order.
Click a thumbnail, a console button or a numbered heading in its notes to open
the original screenshot almost full screen. Keys 1–7 also select screenshots;
Escape or Close returns to the slide. Preview and audience share the selection.
The seven English descriptions form one short script (about 90 seconds including
brief pauses to change screens).
The preview and audience communicate over a session-specific `BroadcastChannel`.
The session is in the console URL; the latest slide and popup are retained in local
storage for refresh recovery. A fresh `/presenter/` URL starts a separate session.
The normal `/deck/index.html` page does not join any session. This console is for
windows in one browser on one computer, not remote tablet synchronization.
If the audience window is blocked, allow popups for localhost and click the button
again. Closing that window leaves the console usable for rehearsal; reopen it to
resume at the current slide. The audience also remains navigable if the console
is closed. Keep the local server running throughout the presentation.
Source files:
- `index.html`, `presenter.css`, `presenter.js`: presenter interface.
- `../deck/presenter-bridge.js`: optional synchronization bridge.
- `PresentationControls` in `../deck/index.html`: slide notes and popup interface.
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AritmoLab | Presenter console</title>
<link rel="stylesheet" href="../deck/fonts.css">
<link rel="stylesheet" href="presenter.css?v=5">
<script src="presenter.js?v=5" defer></script>
</head>
<body>
<header class="toolbar">
<div class="identity"><strong>AritmoLab</strong><span>Presenter console</span></div>
<div class="timer"><output id="elapsed" aria-label="Elapsed time">00:00</output><button id="timer-toggle">Start timer</button><button id="timer-reset" aria-label="Reset timer">Reset</button></div>
<button id="open-audience" class="primary">Open audience window ↗</button>
</header>
<div class="connection"><span id="status" role="status">Loading presentation…</span><span>Keep this window on your Mac. Move the audience window to HDMI, then choose Full screen.</span></div>
<main>
<section class="stage" aria-label="Slide preview and controls">
<div class="section-heading"><h2>On screen <span id="counter">01 / 14</span></h2><label class="sr-only" for="slide-picker">Jump to slide</label><select id="slide-picker" disabled><option>Loading slides…</option></select></div>
<div class="preview-shell"><iframe id="preview" title="Interactive slide preview" allow="fullscreen"></iframe></div>
<div class="slide-controls">
<div class="navigation"><button id="previous" disabled>← Previous</button><button id="next" class="primary" disabled>Next slide →</button><button id="close-popup" disabled hidden>Close popup <kbd>Esc</kbd></button></div>
<div id="popup-controls" aria-label="Slide popups" hidden></div>
</div>
<div class="up-next">
<div class="next-preview" id="next-preview-shell" inert aria-hidden="true">
<iframe id="next-preview" title="Next slide thumbnail" tabindex="-1"></iframe>
</div>
<div><span>UP NEXT</span><p id="next-title">Loading…</p></div>
</div>
<p id="error" role="alert" hidden></p>
</section>
<section class="script" aria-labelledby="notes-heading">
<div class="section-heading"><h2 id="notes-heading">Speaker notes</h2><div class="reading-controls"><button id="smaller" aria-label="Smaller notes">A−</button><button id="larger" aria-label="Larger notes">A+</button><label class="sr-only" for="layout">Notes layout</label><select id="layout"><option value="side">Notes beside preview</option><option value="below">Notes below preview</option></select></div></div>
<div id="reading" tabindex="0" aria-label="Scrollable speaker notes"><div class="slide-label" id="notes-number">SLIDE 01</div><h1 id="notes-title">Preparing your presentation</h1><article id="notes">The slide and its speaker notes will appear here.</article></div>
<footer><span>Notes are visible only in this window.</span><span><kbd>←</kbd> <kbd>→</kbd> Change slide · <kbd>Esc</kbd> Close popup</span></footer>
</section>
</main>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
:root { color-scheme: light; --bg: oklch(96.9% .006 20); --paper: oklch(99.1% .003 20); --ink: oklch(28% .012 10); --muted: oklch(49% .015 10); --line: oklch(86% .009 10); --accent: oklch(43% .105 12); --tint: oklch(93% .025 12); --notes-size: 25px; }
* { box-sizing: border-box; }
body { margin: 0; width: 100%; background: var(--bg); color: var(--ink); font: 16px/1.45 'Source Sans 3', system-ui, sans-serif; height: 100dvh; display: flex; flex-direction: column; }
button, select { font: inherit; color: inherit; background: var(--paper); border: 1px solid var(--line); border-radius: 6px; min-height: 38px; padding: 7px 12px; }
button { cursor: pointer; } button:hover:not(:disabled), select:hover { background: var(--tint); } button:disabled { opacity: .45; cursor: default; }
button.primary { background: var(--accent); color: var(--paper); border-color: var(--accent); font-weight: 600; } button.primary:hover { background: oklch(36% .10 12); }
:focus-visible { outline: 3px solid oklch(62% .14 12); outline-offset: 3px; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 16px 24px; background: var(--paper); border-bottom: 1px solid var(--line); }
.identity { display: flex; gap: 16px; align-items: baseline; } .identity strong { font-size: 23px; letter-spacing: -.6px; } .identity span { color: var(--muted); }
.timer { display: flex; align-items: center; gap: 8px; } .timer output { font-variant-numeric: tabular-nums; font-size: 24px; margin-right: 8px; } .timer button { border: 0; background: transparent; padding-inline: 7px; }
.connection { padding: 9px 24px; display: flex; flex-wrap: wrap; gap: 6px 24px; font-size: 13px; color: var(--muted); border-bottom: 1px solid var(--line); }
#status { font-weight: 600; color: var(--accent); } #status.connected { color: oklch(40% .075 160); }
main { display: grid; grid-template-columns: minmax(340px, 44%) minmax(0, 1fr); flex: 1; min-height: 0; }
.stage { padding: 16px 20px; overflow-y: auto; min-width: 0; display: grid; grid-template-rows: auto var(--current-preview-height, 0px) auto minmax(0, 1fr) auto; gap: 10px; } .section-heading { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 0; }
h2 { margin: 0; font-size: 16px; font-weight: 600; white-space: nowrap; } #counter { color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; margin-left: 8px; }
#slide-picker { min-width: 0; width: 52%; text-overflow: ellipsis; font-size: 14px; }
.preview-shell, .next-preview { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: var(--paper); border: 1px solid var(--line); }
.stage > .preview-shell { width: 100%; height: 100%; min-height: 0; aspect-ratio: auto; justify-self: center; border: 0; background: transparent; }
#preview, #next-preview { position: absolute; left: 50%; top: 50%; width: 1280px; height: 720px; transform-origin: center; border: 0; display: block; }
#next-preview { pointer-events: none; }
#preview { height: 686px; }
.next-preview[hidden] { display: none; }
.navigation { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .navigation button { min-height: 36px; }
.navigation:has(#close-popup:not([hidden])) { grid-template-columns: 1fr 1fr auto; }
#close-popup { font-size: 12px; padding-inline: 8px; }
#popup-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
#popup-controls:not([hidden]) { margin-top: 8px; }
#popup-controls[hidden] { display: none; }
#popup-controls button { display: flex; align-items: center; gap: 3px; text-align: left; font-size: 15px; font-weight: 700; line-height: 1.2; min-height: 38px; padding: 4px 6px; width: 100%; overflow-wrap: anywhere; }
#popup-controls button > span:last-child { min-width: 0; }
.popup-number { font-size: 23px; font-weight: 800; line-height: 1; flex: none; }
#popup-controls button[aria-pressed="true"] { background: var(--tint); border-color: var(--accent); color: var(--accent); }
.popup-group { min-width: 0; }
.popup-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
.popup-group-lens { grid-column: 1 / -1; }
.popup-group-lens .popup-buttons { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.popup-group-lens .popup-buttons:has(> button:nth-child(4):last-child) { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.popup-group-screenshot { grid-column: 1 / -1; }
.popup-group-screenshot .popup-buttons { grid-template-columns: repeat(7, minmax(0, 1fr)); }
#popup-controls .popup-group-screenshot button { justify-content: center; }
.popup-group-screenshot button > span { display: none; }
#popup-controls .popup-group-lens button { flex-direction: column; justify-content: center; text-align: center; }
.popup-group-lens button > span:nth-child(2) { display: none; }
.popup-group-ai { grid-column: 1 / -1; } .popup-group-ai .popup-buttons { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.control-ai-icon { width: 27px; height: 27px; flex: 0 0 27px; margin-right: 3px; }
.up-next { border-top: 1px solid var(--line); padding-top: 10px; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; min-height: 0; }
.up-next .next-preview { height: 100%; min-height: 0; aspect-ratio: auto; border: 0; background: transparent; }
.up-next > div:last-child { display: flex; align-items: baseline; gap: 12px; min-width: 0; }
.up-next span, .slide-label { color: var(--muted); font-size: 12px; letter-spacing: .09em; }
.up-next span { flex: none; } .up-next p { margin: 0; font-size: 16px; overflow-wrap: anywhere; }
.up-next:has(.next-preview[hidden]) { grid-template-rows: auto; align-content: start; }
@media (min-width: 621px) {
/* The current preview keeps its no-popup height; the next takes the remainder. */
body:not([data-layout="below"]) .up-next { display: contents; }
body:not([data-layout="below"]) .next-preview { grid-row: 4; }
body:not([data-layout="below"]) .up-next > div:last-child { grid-row: 5; }
body:not([data-layout="below"]) #next-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
}
.script { background: var(--paper); border-left: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.script > .section-heading { margin: 0; padding: 20px 28px 16px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.reading-controls { display: flex; align-items: center; gap: 6px; } .reading-controls select { font-size: 13px; max-width: 185px; } .reading-controls button { font-size: 17px; }
#reading { overflow: auto; padding: 22px 28px 40px; flex: 1; min-width: 0; scroll-behavior: auto; } h1 { font-size: 27px; line-height: 1.2; letter-spacing: -.3px; margin: 8px 0 24px; overflow-wrap: anywhere; }
#notes { font-size: var(--notes-size); line-height: 1.48; white-space: normal; width: 100%; overflow-wrap: anywhere; } #notes p { margin: 0 0 .85em; } #notes strong { font-weight: 600; } #notes .notes-authored { white-space: pre-line; } .notes-draft { font-size: 13px; color: var(--muted); margin-bottom: 16px; }
#notes [data-note-lens], #notes [data-note-screenshot] {
min-height: 0; padding: 0; border: 0; border-bottom: 1px dotted currentColor; border-radius: 0;
background: transparent; color: var(--accent); font: inherit; font-weight: 600;
text-align: left; cursor: pointer;
}
#notes [data-note-lens]:hover, #notes [data-note-screenshot]:hover { background: var(--tint); }
#notes [data-note-lens]:focus-visible, #notes [data-note-screenshot]:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
footer { border-top: 1px solid var(--line); padding: 12px 28px; font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px 20px; justify-content: space-between; }
kbd { font: 12px system-ui; background: var(--bg); border: 1px solid var(--line); border-radius: 3px; padding: 1px 4px; } #error { color: var(--accent); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
body[data-layout="below"] main { grid-template-columns: 1fr; grid-template-rows: minmax(240px, 42%) minmax(0, 1fr); }
body[data-layout="below"] .stage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(150px, .65fr); grid-template-rows: auto minmax(0, 1fr); column-gap: 24px; padding: 14px 24px; }
body[data-layout="below"] .stage > .section-heading { grid-column: 1; }
body[data-layout="below"] .preview-shell { grid-column: 1; grid-row: 2; width: 100%; height: 100%; aspect-ratio: auto; min-height: 100px; }
body[data-layout="below"] .slide-controls { grid-column: 2; grid-row: 1 / 3; overflow: auto; min-height: 0; }
body[data-layout="below"] .navigation { margin: 0 0 16px; }
body[data-layout="below"] .up-next { grid-column: 3; grid-row: 1 / 3; border-top: 0; padding-top: 0; overflow: auto; }
body[data-layout="below"] .up-next > div:last-child { display: block; }
body[data-layout="below"] .stage .hint { display: none; } body[data-layout="below"] .script { border-left: 0; border-top: 1px solid var(--line); }
@media (max-width: 850px) { .identity span, .timer #timer-reset { display: none; } .toolbar { gap: 10px; padding: 12px; } main { grid-template-columns: minmax(280px, 42%) minmax(0,1fr); } .stage { padding: 16px; } #reading { padding: 22px; } .script > .section-heading { padding: 16px; } }
@media (max-width: 1000px) { body[data-layout="below"] .stage { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto auto auto; overflow: auto; } body[data-layout="below"] .preview-shell { height: auto; aspect-ratio: 16/9; } body[data-layout="below"] .up-next { grid-column: 1; grid-row: 3; display: flex; padding-top: 14px; } body[data-layout="below"] .up-next .next-preview { margin: 0; } body[data-layout="below"] .slide-controls { grid-row: 1 / 4; overflow: visible; } }
@media (max-width: 620px) { body { height: auto; min-height: 100dvh; } .toolbar { flex-wrap: wrap; } main, body[data-layout="below"] main { display: block; } body[data-layout="below"] .stage { display: block; } body[data-layout="below"] .navigation { margin-top: 14px; } .script { min-height: 65vh; } .connection { padding: 10px 12px; } #reading { max-height: 70vh; } .timer output { font-size: 20px; } .section-heading { flex-wrap: wrap; } .reading-controls { flex-wrap: wrap; } #slide-picker { width: 100%; } }
@media (max-width: 620px) { .stage { display: block; } .stage > .preview-shell, body[data-layout="below"] .stage > .preview-shell { width: 100%; aspect-ratio: 1280/686; height: auto; margin: 12px 0; } .up-next, body[data-layout="below"] .up-next { display: grid; grid-template-rows: auto auto; margin-top: 12px; } .up-next .next-preview { aspect-ratio: 16/9; height: auto; } }
+263
View File
@@ -0,0 +1,263 @@
(() => {
const $ = id => document.getElementById(id);
const params = new URLSearchParams(location.search);
const session = params.get('session') || crypto.randomUUID();
params.set('session', session);
history.replaceState(null, '', `${location.pathname}?${params}`);
const channel = new BroadcastChannel(`aritmolab-presenter:${session}`);
const sender = crypto.randomUUID();
const send = (type, extra = {}) => channel.postMessage({ type, role: 'console', sender, ...extra });
const command = (action, extra = {}) => send('command', { action, ...extra });
const preview = $('preview');
const nextPreview = $('next-preview');
let nextPreviewReady = false;
let slides = [];
let current = null;
let audience = null;
let lastAudienceSeen = 0;
let lastPreviewSeen = 0;
let notesSize = 25;
let blocked = false;
const urlFor = role => `../deck/index.html?view=${role}&session=${encodeURIComponent(session)}`;
const pad = value => String(value).padStart(2, '0');
function showNoteLens(event) {
const cue = event.target.closest('[data-note-lens]');
if (!cue || !slides[current?.index]?.popups.some(popup => popup.kind === 'lens' && popup.key === cue.dataset.noteLens)) return;
if (current?.popup?.key !== cue.dataset.noteLens) command('popup', { popup: { kind: 'lens', key: cue.dataset.noteLens } });
}
$('notes').addEventListener('click', showNoteLens);
$('notes').addEventListener('click', event => {
const cue = event.target.closest('[data-note-screenshot]');
if (cue && slides[current?.index]?.popups.some(popup => popup.kind === 'screenshot' && popup.key === cue.dataset.noteScreenshot)) {
command('popup', { popup: { kind: 'screenshot', key: cue.dataset.noteScreenshot } });
}
});
function renderNotes(html) {
const template = document.createElement('template');
template.innerHTML = html;
const target = $('notes');
target.replaceChildren();
// Preserve authored rich text when available; plain scripts get reading paragraphs.
if (template.content.querySelector('p, ul, ol, h3, blockquote')) {
target.append(template.content.cloneNode(true));
return;
}
if (template.content.querySelector('*')) {
const script = document.createElement('div');
script.className = 'notes-authored';
script.append(template.content.cloneNode(true));
target.append(script);
return;
}
let text = template.content.textContent.trim();
const draft = text.match(/^\[DRAFT[^\]]*\]/);
if (draft) {
const label = document.createElement('div');
label.className = 'notes-draft';
label.textContent = draft[0];
target.append(label, document.createTextNode('\n'));
text = text.slice(draft[0].length).trim();
}
const segmenter = new Intl.Segmenter('en', { granularity: 'sentence' });
const paragraphs = [];
for (const line of text.split(/\n+/).map(line => line.trim()).filter(Boolean)) {
let paragraph = '';
for (const { segment } of segmenter.segment(line)) {
if (paragraph.length >= 240 && paragraph.length + segment.length > 390) {
paragraphs.push(paragraph.trim());
paragraph = '';
}
paragraph += segment;
}
if (paragraph.trim()) paragraphs.push(paragraph.trim());
}
for (const text of paragraphs.length ? paragraphs : ['No speaker notes for this slide.']) {
const paragraph = document.createElement('p');
for (const part of text.split(/(\b(?:Cardioref|Omics Portal|AritmoLab|ThothII|Tier 1|Tier 2)\b)/g)) {
if (/^(Cardioref|Omics Portal|AritmoLab|ThothII|Tier 1|Tier 2)$/.test(part)) {
const strong = document.createElement('strong'); strong.textContent = part; paragraph.append(strong);
} else paragraph.append(document.createTextNode(part));
}
target.append(paragraph, document.createTextNode('\n'));
}
}
function updateNextPreview() {
const index = (current?.index ?? 0) + 1;
const hasNext = index < slides.length;
$('next-preview-shell').hidden = !hasNext;
if (hasNext && nextPreviewReady) nextPreview.contentWindow.Reveal.slide(index);
}
function render(state) {
if (!state || !slides[state.index]) return;
const changed = !current || current.index !== state.index;
current = state;
const slide = slides[state.index];
$('counter').textContent = `${pad(state.index + 1)} / ${pad(slides.length)}`;
$('slide-picker').value = state.index;
$('previous').disabled = state.index === 0;
$('next').disabled = state.index === slides.length - 1;
$('close-popup').disabled = !state.popup;
if (changed) {
$('notes-number').textContent = `SLIDE ${pad(state.index + 1)}`;
$('notes-title').textContent = slide.title;
// Trusted, same-origin authored notes, never channel-provided HTML.
renderNotes(slide.notes);
$('reading').scrollTop = 0;
$('next-title').textContent = slides[state.index + 1]?.title || 'End of presentation';
updateNextPreview();
$('popup-controls').replaceChildren();
$('popup-controls').hidden = !slide.popups.length;
$('close-popup').hidden = !slide.popups.length;
let popupNumber = 0;
const groups = ['lens', 'screenshot', 'start', 'missing', 'ai'].flatMap(kind => {
const popups = slide.popups.filter(popup => popup.kind === kind);
return popups.length ? [{ kind, popups }] : [];
});
for (const { kind, popups } of groups) {
const group = document.createElement('section');
group.className = `popup-group popup-group-${kind}`;
group.setAttribute('aria-label', { lens: 'Enlargements', screenshot: 'Screenshots', start: 'Source systems', missing: 'Missing pieces', ai: 'AI contributions' }[kind]);
const buttons = document.createElement('div');
buttons.className = 'popup-buttons';
group.append(buttons);
$('popup-controls').append(group);
for (const popup of popups) {
const button = document.createElement('button');
const number = document.createElement('strong');
number.className = 'popup-number';
number.textContent = ++popupNumber;
const separator = document.createElement('span');
separator.textContent = ' - ';
const label = document.createElement('span');
label.textContent = popup.title.split(' — ')[0];
button.append(number, separator, label);
if (kind === 'ai') {
const icon = document.createElement('img');
icon.src = '../deck/artificial-brain.svg'; icon.alt = ''; icon.className = 'control-ai-icon';
button.insertBefore(icon, number);
}
button.title = popup.title;
button.dataset.kind = popup.kind;
button.dataset.key = popup.key;
button.onclick = () => {
if (kind === 'lens') { command('popup', { popup: { kind: popup.kind, key: popup.key } }); return; }
if (current?.popup?.kind === popup.kind && current.popup.key === popup.key) command('close');
else command('popup', { popup: { kind: popup.kind, key: popup.key } });
};
buttons.append(button);
}
}
}
$('popup-controls').querySelectorAll('button').forEach(button => {
button.setAttribute('aria-pressed', String(state.popup?.kind === button.dataset.kind && state.popup?.key === button.dataset.key));
});
}
channel.onmessage = ({ data }) => {
if (!data || data.sender === sender) return;
if (data.role === 'audience') lastAudienceSeen = data.type === 'departed' ? 0 : Date.now();
if (data.role === 'preview') lastPreviewSeen = data.type === 'departed' ? 0 : Date.now();
if (data.type === 'state') render(data.state);
updateStatus();
};
function updateStatus() {
const connected = Date.now() - lastAudienceSeen < 4500;
const previewReady = slides.length > 0 && Date.now() - lastPreviewSeen < 4500;
$('status').textContent = !previewReady ? 'Connecting to slide preview…' : connected ? 'Audience connected' : blocked ? 'Window blocked. Allow popups and click Open audience window again.' : 'Preview only. Open the audience window to present.';
$('status').classList.toggle('connected', connected);
}
$('open-audience').onclick = () => {
if (audience && !audience.closed) { audience.focus(); return; }
audience = window.open(urlFor('audience'), `aritmolab-audience-${session}`, 'popup=yes,width=1280,height=760');
blocked = !audience;
updateStatus();
};
preview.addEventListener('load', async () => {
try {
const win = preview.contentWindow;
if (!win.Reveal) throw new Error('Slide preview could not load. Check that the local server is running.');
if (!win.Reveal.isReady()) await new Promise(resolve => win.Reveal.on('ready', resolve));
slides = win.PresentationControls.slides();
$('slide-picker').replaceChildren(...slides.map((slide, index) => new Option(`${pad(index + 1)} · ${slide.title}`, index)));
$('slide-picker').disabled = false;
current = null;
render(win.PresentationControls.snapshot());
send('request');
} catch (error) { $('error').hidden = false; $('error').textContent = error.message; }
});
preview.src = urlFor('preview');
nextPreview.addEventListener('load', async () => {
const reveal = nextPreview.contentWindow.Reveal;
if (!reveal) return;
if (!reveal.isReady()) await new Promise(resolve => reveal.on('ready', resolve));
reveal.configure({ keyboard: false, touch: false, overview: false, transition: 'none' });
nextPreviewReady = true;
updateNextPreview();
});
// No session token: the thumbnail must never navigate or publish to the audience.
nextPreview.src = '../deck/index.html?view=thumbnail#/1';
const stage = document.querySelector('.stage');
function sizeCurrentPreview() {
const style = getComputedStyle(stage);
const navigation = getComputedStyle($('previous'));
// Use the one-line navigation height, never the popup-dependent controls height.
const navigationHeight = Math.max(parseFloat(navigation.minHeight),
parseFloat(navigation.lineHeight) + parseFloat(navigation.paddingTop) +
parseFloat(navigation.paddingBottom) + parseFloat(navigation.borderTopWidth) +
parseFloat(navigation.borderBottomWidth));
const available = stage.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
- stage.querySelector('.section-heading').getBoundingClientRect().height
- navigationHeight - parseFloat(getComputedStyle($('next-title')).lineHeight)
- 4 * parseFloat(style.rowGap);
// Do not reserve empty bands when the slide is already limited by column width.
const widthLimitedHeight = (stage.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)) * 686 / 1280;
stage.style.setProperty('--current-preview-height', `${Math.max(0, Math.min(available * .65, widthLimitedHeight))}px`);
}
const stageSizes = new ResizeObserver(sizeCurrentPreview);
stageSizes.observe(stage);
stageSizes.observe(stage.querySelector('.section-heading'));
stageSizes.observe($('previous'));
sizeCurrentPreview();
function fitPreviews() {
const currentBox = preview.parentElement;
const nextBox = nextPreview.parentElement;
const currentScale = Math.min(currentBox.clientWidth / 1280, currentBox.clientHeight / 686);
// The next slide includes its footer: cap its rendered height as well as its width.
const nextScale = Math.min(nextBox.clientWidth / 1280, nextBox.clientHeight / 720, currentScale * 686 / 720);
preview.style.transform = `translate(-50%, -50%) scale(${currentScale})`;
nextPreview.style.transform = `translate(-50%, -50%) scale(${nextScale})`;
}
const previewSizes = new ResizeObserver(fitPreviews);
for (const frame of [preview, nextPreview]) previewSizes.observe(frame.parentElement);
$('previous').onclick = () => command('previous');
$('next').onclick = () => command('next');
$('close-popup').onclick = () => command('close');
$('slide-picker').onchange = event => command('slide', { index: Number(event.target.value) });
$('layout').onchange = event => { document.body.dataset.layout = event.target.value; };
function fontSize(delta) {
notesSize = Math.min(40, Math.max(18, notesSize + delta));
document.documentElement.style.setProperty('--notes-size', `${notesSize}px`);
$('smaller').disabled = notesSize === 18; $('larger').disabled = notesSize === 40;
}
$('smaller').onclick = () => fontSize(-2); $('larger').onclick = () => fontSize(2);
document.addEventListener('keydown', event => {
if (event.target.closest('input, select, textarea, [contenteditable="true"]')) return;
if (/^[1-9]$/.test(event.key) && !event.repeat && !event.altKey && !event.ctrlKey && !event.metaKey) {
const lens = [...$('popup-controls').querySelectorAll('[data-kind="lens"], [data-kind="screenshot"]')]
.find(button => button.querySelector('.popup-number').textContent === event.key);
if (lens) { event.preventDefault(); lens.click(); return; }
}
const action = { ArrowRight: 'next', ArrowLeft: 'previous', Escape: 'close' }[event.key];
if (action) { event.preventDefault(); command(action); }
});
let elapsed = 0, started = null;
$('timer-toggle').onclick = () => {
if (started === null) { started = Date.now(); $('timer-toggle').textContent = 'Pause timer'; }
else { elapsed += Date.now() - started; started = null; $('timer-toggle').textContent = 'Start timer'; }
};
$('timer-reset').onclick = () => { elapsed = 0; if (started !== null) started = Date.now(); };
setInterval(() => {
const seconds = Math.floor((elapsed + (started === null ? 0 : Date.now() - started)) / 1000);
$('elapsed').textContent = `${pad(Math.floor(seconds / 60))}:${pad(seconds % 60)}`;
updateStatus();
}, 500);
})();