/* ============================================================================= deck.js — the reusable engine for the Africa Multiple / Bayreuth deck. This file is GENERIC. You should not need to edit it per talk. Each deck only edits index.html: its slides, and the `window.DECK_CONFIG` block near the top of that file (presenter, talk metadata, contact links). What it does, in order: • fills every [data-contact] slot with icon links from DECK_CONFIG • initialises reveal.js (custom nav, no stock chrome) with plugins • builds the running head (current section) and persistent footer • builds the auto table-of-contents overlay from [data-toc] sections • keeps everything in sync on each slide change Keyboard: ←/→ navigate · G jump to slide (type a number, Enter) · T table of contents · O overview · F fullscreen · S speaker notes Ctrl/Cmd+Shift+F search across slides · ? help · Esc closes overlays ============================================================================= */ (function () { "use strict"; var CFG = window.DECK_CONFIG || {}; CFG.links = CFG.links || {}; /* ---- engine UI strings, language-aware (from ; default English). Add a language by extending I18N; decks opt in via . Every string the engine shows or announces lives here — a string spelled out at its call site is one a new language silently misses. -------------- */ var LANG = (CFG.lang || document.documentElement.lang || "en").slice(0, 2).toLowerCase(); var I18N = { en: { contents: "Contents", overview: "overview", close: "close", prev: "Previous slide", next: "Next slide", tocOpen: "Open table of contents", tocAria: "Table of contents", closeAria: "Close", deckNav: "Slide navigation", copyLink: "Copy link to this slide", linkCopied: "Link copied", imageViewer: "Image viewer", imageClose: "Close image", imageView: "View image full screen", imagePrev: "Previous image", imageNext: "Next image", imageLoading: "Loading image…", imageUnavailable: "Image unavailable. Try another image.", embedLoading: "Loading file…", embedError: "Could not load the file.", embedSource: "View the source", scrollRegion: "Scrollable excerpt", frameNetwork: "This website uses a network connection.", frameShow: "Show the site here", frameUnavailable: "Live view unavailable — it needs a network connection.", frameOpen: "Open the site" }, fr: { contents: "Sommaire", overview: "aperçu", close: "fermer", prev: "Diapo précédente", next: "Diapo suivante", tocOpen: "Ouvrir le sommaire", tocAria: "Sommaire", closeAria: "Fermer", deckNav: "Navigation des diapositives", copyLink: "Copier le lien de cette diapositive", linkCopied: "Lien copié", imageViewer: "Visionneuse d’images", imageClose: "Fermer l’image", imageView: "Afficher l’image en plein écran", imagePrev: "Image précédente", imageNext: "Image suivante", imageLoading: "Chargement de l’image…", imageUnavailable: "Image indisponible. Essayez une autre image.", embedLoading: "Chargement du fichier…", embedError: "Impossible de charger le fichier.", embedSource: "Voir la source", scrollRegion: "Extrait défilant", frameNetwork: "Ce site utilise une connexion réseau.", frameShow: "Afficher le site ici", frameUnavailable: "Aperçu en direct indisponible — une connexion réseau est requise.", frameOpen: "Ouvrir le site" } }; var STR = I18N[LANG] || I18N.en; /* Dev-mode flags: ?check outlines overflow + shows fit scales; ?no-fit (or ?audit) disables auto-fitting so authored overflow is visible raw. */ var CHECK_MODE = /[?&](check|audit)\b/.test(location.search); var NO_FIT = /[?&](no-fit|audit)\b/.test(location.search); var PARAMS = new URLSearchParams(location.search); var PRINT = PARAMS.has("print-pdf") || PARAMS.get("view") === "print"; var motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)"); var fileEmbedsReady = Promise.resolve(); function leafSlides() { return Array.from(document.querySelectorAll(".reveal .slides section")) .filter(function (s) { return !s.querySelector(":scope > section") && !s.classList.contains("stack"); }); } function leafSlideFor(el) { return el && el.closest ? el.closest("section:not(.stack)") : null; } function imageSource(img) { return img.currentSrc || img.getAttribute("src") || img.getAttribute("data-src") || ""; } function loadImage(img) { if (img.dataset.srcset) img.srcset = img.dataset.srcset; if (!img.getAttribute("src") && img.dataset.src) img.src = img.dataset.src; if (!img.getAttribute("src") && !img.getAttribute("srcset")) return Promise.resolve(); return new Promise(function (resolve) { var timer = setTimeout(done, 10000); function done() { clearTimeout(timer); img.removeEventListener("load", done); img.removeEventListener("error", done); resolve(); } img.addEventListener("load", done); img.addEventListener("error", done); if (img.complete) done(); }).then(function () { return img.complete && img.naturalWidth && img.decode ? img.decode().catch(function () {}) : null; }); } async function settleSlides(slides) { await fileEmbedsReady; if (document.fonts) await document.fonts.ready; await Promise.all(slides.flatMap(function (s) { return Array.from(s.querySelectorAll("img")).map(loadImage); })); Reveal.layout(); fitReady = true; slides.forEach(function (s) { fitSlide(s, true); }); if (Reveal.isPrintView()) buildPrintImprints(); await new Promise(function (resolve) { requestAnimationFrame(function () { requestAnimationFrame(resolve); }); }); } window.DeckRuntime = { leafSlides: leafSlides, settle: function () { return settleSlides(Reveal.isPrintView() ? leafSlides() : [Reveal.getCurrentSlide()].filter(Boolean)); } }; // Folder this script lives in (e.g. .../shared/) so engine assets resolve no // matter how deep the talk page sits. Captured while currentScript is valid. var SCRIPT_BASE = (function () { var s = document.currentScript; if (!s) { var all = document.getElementsByTagName("script"); for (var i = 0; i < all.length; i++) if (/deck\.js(\?|$)/.test(all[i].src)) { s = all[i]; break; } } return s && s.src ? s.src.replace(/[^\/]*$/, "") : ""; })(); /* ---- inline icons (stroke icons inherit currentColor; ORCID keeps its mark) */ var ICON = { prev: '', next: '', toc: '', close:'', link: '', check:'', github:'', globe:'', mail: '', orcid:'', linkedin:'' }; function elem(html) { var t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstElementChild; } /* Deck titles are author copy, and this engine builds its chrome by string concatenation. A title carrying &, <, or a quote would otherwise land in the markup as markup. */ function escapeHTML(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } function tidyUrl(u) { return String(u || "").replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, ""); } /* ---- contact links from DECK_CONFIG.links, into every [data-contact] slot */ function contactHTML() { var L = CFG.links, out = []; function row(href, icon, label) { if (!/^(https?:|mailto:)/i.test(href)) return ""; return '' + icon + "" + escapeHTML(label) + ""; } if (L.github) out.push(row(L.github, ICON.github, tidyUrl(L.github).replace(/^github\.com\//, ""))); if (L.website) out.push(row(L.website, ICON.globe, tidyUrl(L.website))); if (L.orcid) out.push(row(L.orcid, ICON.orcid, tidyUrl(L.orcid).replace(/^orcid\.org\//, ""))); if (L.linkedin)out.push(row(L.linkedin, ICON.linkedin, tidyUrl(L.linkedin).replace(/^linkedin\.com\/in\//, ""))); if (L.email) out.push(row("mailto:" + L.email, ICON.mail, L.email)); return out.join(""); } /* ---- footer (single persistent element, updated per slide) -------------- */ var footer, counterCur, counterTot, btnPrev, btnNext; function buildFooter(reveal) { var amLogo = CFG.logoMark || (SCRIPT_BASE + "logo-africamultiple.png"); footer = elem( '" ); reveal.appendChild(footer); counterCur = footer.querySelector(".counter .cur"); counterTot = footer.querySelector(".counter .tot"); btnPrev = footer.querySelector(".prev"); btnNext = footer.querySelector(".next"); btnPrev.addEventListener("click", function () { Reveal.prev(); }); btnNext.addEventListener("click", function () { Reveal.next(); }); footer.querySelector(".toc-btn").addEventListener("click", toggleTOC); var copy = document.createElement("button"); copy.className = "deck-btn copy-link"; copy.type = "button"; copy.title = STR.copyLink; copy.setAttribute("aria-label", STR.copyLink); copy.innerHTML = ICON.link; var status = document.createElement("span"); status.className = "deck-status"; status.setAttribute("role", "status"); footer.querySelector(".deck-nav").append(copy, status); /* The status line is for screen readers only, so on its own a sighted presenter clicked and saw nothing happen. The button answers for itself: its icon turns to a tick for a moment. Both then reset, so a second copy is announced and shown again rather than landing on text already there. */ var copiedTimer = null; function resetCopied() { clearTimeout(copiedTimer); copiedTimer = null; copy.classList.remove("is-copied"); copy.innerHTML = ICON.link; status.textContent = ""; } copy.addEventListener("click", async function () { resetCopied(); var canonical = document.querySelector('link[rel="canonical"]'); var url = new URL(canonical ? canonical.href : location.href); url.search = ""; url.hash = "/" + encodeURIComponent(Reveal.getCurrentSlide().id); try { await navigator.clipboard.writeText(url.href); copy.classList.add("is-copied"); copy.innerHTML = ICON.check; status.textContent = STR.linkCopied; copiedTimer = setTimeout(resetCopied, 2400); } catch (error) { window.prompt(STR.copyLink, url.href); } }); } /* ---- counted vs annexe slides ------------------------------------------ A deck may keep backup material after its closing slide for questions, marked data-visibility="uncounted" (reveal's own attribute). Reveal still navigates to it and it still prints, but it must never inflate the folio the room reads. One folio vocabulary answers that everywhere the deck numbers itself — footer counter, contents list, printed imprint: counted slides run 1…N, annexe slides take an A-series of their own, and while an annexe slide is on screen the live counter holds at N / N rather than counting past its own total. The stronger data-visibility="hidden" needs nothing here — reveal removes those slides at init. */ function isUncounted(sec) { return !!sec && sec.getAttribute("data-visibility") === "uncounted"; } function pad2(n) { return String(n).padStart(2, "0"); } /* The counted folio at a horizontal index: {cur, total} over the counted slides alone. On an annexe slide cur is the last counted slide before it. */ function countedFolio(h) { var cur = 0, total = 0; Reveal.getHorizontalSlides().forEach(function (sec, i) { if (isUncounted(sec)) return; total++; if (i <= h) cur = total; }); return { cur: cur || 1, total: total || 1 }; } /* ---- per-page imprint (PDF export only) --------------------------------- */ /* On screen one persistent footer is right: it updates as you move. On paper there is nothing to update — every page is final and needs its own folio, so each printed page gets an imprint of its own instead. The PDF is the shareable record, so annexe pages print with the rest — but they are uncounted, so they cannot take a number out of the deck's own sequence. Counted pages read NN / TT against the counted total; annexe pages read A01, A02 … The alternative — repeating the closing page's "25 / 25" across every annexe page — would read as a printing fault where a plainly different mark reads as a decision, and this way the reader can see at a glance where the talk ended. */ function buildPrintImprints() { var pages = document.querySelectorAll(".reveal .slides .pdf-page"); if (!pages.length) return; var counted = 0; Array.prototype.forEach.call(pages, function (page) { if (!isUncounted(page.querySelector("section"))) counted++; }); var total = pad2(counted); var title = escapeHTML(CFG.talkShort || CFG.talkTitle || ""); if (CFG.venue) title += " · " + escapeHTML(CFG.venue); var n = 0, annexe = 0; Array.prototype.forEach.call(pages, function (page) { var sec = page.querySelector("section"); // The folios advance for every page, so a page that already carries an // imprint (a re-run of the pass) cannot shift the numbering behind it. var folio = isUncounted(sec) ? "A" + pad2(++annexe) : pad2(++n) + " / " + total; if (page.querySelector(".pdf-imprint")) return; var field = fieldOf(sec); var mark = elem( '" ); page.appendChild(mark); }); } /* ---- running head (current section, like a book) ------------------------ */ var runhead, runSec; var HERO = ["cover", "section", "closing", "statement", "media"]; function buildRunhead(reveal) { runhead = elem(''); reveal.appendChild(runhead); runSec = runhead.querySelector(".rh-sec"); } /* The running section is the latest section title / [data-toc] at or before the current slide — resolved by scanning forward so TOC jumps stay correct. */ function sectionLabelFor(h) { var hSlides = Reveal.getHorizontalSlides(), label = ""; for (var i = 0; i <= h && i < hSlides.length; i++) { var sec = hSlides[i]; if (sec.classList.contains("section")) { var t = sec.querySelector("h2"); label = (t ? t.textContent : sec.getAttribute("data-toc") || label).trim(); } else if (sec.getAttribute("data-toc")) { label = sec.getAttribute("data-toc"); } } return label; } /* ---- table-of-contents overlay ------------------------------------------ */ var overlay, tocItems = []; function buildTOC(reveal) { /* Reveal's scroll view (?view=scroll) re-parents every slide out of `.slides > section` — the selector getHorizontalSlides() reads — so the list below has nothing to enumerate: it built an empty overlay on one deck and a one-entry one on another, and the folio it printed could not agree with the (equally unreachable) footer counter. The chrome for that view hides the Contents button; leaving the overlay unbuilt is what stops the T key from opening a dialog listing a deck that isn't there. */ if (Reveal.isScrollView && Reveal.isScrollView()) return; var hSlides = Reveal.getHorizontalSlides(); var entries = []; // The right-hand folio is the same one the footer and the printed imprint // spend, so an annexe entry reads A01 rather than a number the counter on // that very slide would contradict. var counted = 0, annexe = 0; hSlides.forEach(function (sec, h) { var folio = isUncounted(sec) ? "A" + pad2(++annexe) : pad2(++counted); var label = sec.getAttribute("data-toc"); if (label) entries.push({ h: h, label: label, folio: folio }); }); if (!entries.length) return; // no TOC requested var rows = entries.map(function (e, i) { return '
  • "; }).join(""); overlay = elem( '" ); reveal.appendChild(overlay); tocItems = Array.prototype.slice.call(overlay.querySelectorAll(".toc-item")); tocItems.forEach(function (btn) { btn.addEventListener("click", function () { Reveal.slide(parseInt(btn.getAttribute("data-h"), 10), 0); closeTOC(); }); }); overlay.querySelector(".toc-close").addEventListener("click", closeTOC); overlay.addEventListener("click", function (e) { if (e.target === overlay) closeTOC(); }); setDialogHidden(overlay, true); } /* Keep an overlay's accessibility state in sync with its visual state: `inert` (with aria-hidden fallback) while closed, interactive while open. */ function setDialogHidden(dialog, hiddenState) { if (!dialog) return; if ("inert" in dialog) dialog.inert = hiddenState; if (hiddenState) dialog.setAttribute("aria-hidden", "true"); else dialog.removeAttribute("aria-hidden"); } /* Focus an element once its dialog is actually visible: the overlays fade in via a visibility transition, and focus() is a no-op while the computed visibility is still hidden (the first frame after the class flips). */ function focusWhenVisible(el) { if (!el) return; requestAnimationFrame(function () { requestAnimationFrame(function () { el.focus(); }); }); } var tocLastFocus = null; function openTOC() { if (!overlay) return; tocLastFocus = document.activeElement; overlay.classList.add("open"); setDialogHidden(overlay, false); markCurrentTOC(); // Move focus into the dialog (current entry if any, else the first). focusWhenVisible(overlay.querySelector(".toc-item.current") || overlay.querySelector(".toc-item")); } function closeTOC() { if (!overlay) return; overlay.classList.remove("open"); setDialogHidden(overlay, true); if (tocLastFocus && tocLastFocus.focus) tocLastFocus.focus(); // restore focus to the trigger tocLastFocus = null; } function toggleTOC(){ if (overlay) (overlay.classList.contains("open") ? closeTOC() : openTOC()); } /* While the TOC is open, keys must act on the DIALOG, never on the deck behind it: Tab is trapped inside, arrows move between entries, Escape closes, and everything else is stopped before reveal's own key handler. Runs in the capture phase on document so it wins over Reveal. */ function tocKeydown(e) { if (!overlay || !overlay.classList.contains("open")) return; if (e.key === "Tab") { trapTOCFocus(e); e.stopPropagation(); return; } if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeTOC(); return; } // The dialog's own footer names T and O, so both have to work from inside // it rather than be swallowed with every other key: T is the same toggle // that opened the contents, and O hands over to reveal's overview. if (!e.ctrlKey && !e.metaKey && !e.altKey) { var letter = String(e.key || "").toLowerCase(); if (letter === "t" || letter === "o") { e.preventDefault(); e.stopPropagation(); closeTOC(); if (letter === "o") Reveal.toggleOverview(true); return; } } var f = Array.prototype.slice.call( overlay.querySelectorAll(".toc-item") ).filter(function (el) { return !el.disabled && el.offsetParent !== null; }); var idx = f.indexOf(document.activeElement); if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); if (f.length) f[(idx + 1 + f.length) % f.length].focus(); } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); if (f.length) f[(idx - 1 + f.length) % f.length].focus(); } else if (e.key === "Home") { e.preventDefault(); if (f.length) f[0].focus(); } else if (e.key === "End") { e.preventDefault(); if (f.length) f[f.length - 1].focus(); } // Whatever the key, never let it drive the presentation behind the dialog. e.stopPropagation(); } /* Keep Tab inside the open dialog (simple focus trap). */ function trapTOCFocus(e) { if (e.key !== "Tab" || !overlay || !overlay.classList.contains("open")) return; var f = Array.prototype.slice.call( overlay.querySelectorAll(".toc-close, .toc-item") ).filter(function (el) { return !el.disabled && el.offsetParent !== null; }); if (!f.length) return; var first = f[0], last = f[f.length - 1], a = document.activeElement; if (e.shiftKey && (a === first || !overlay.contains(a))) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && (a === last || !overlay.contains(a))) { e.preventDefault(); first.focus(); } } function markCurrentTOC() { if (!overlay) return; var h = Reveal.getIndices().h; var active = null; tocItems.forEach(function (btn) { var bh = parseInt(btn.getAttribute("data-h"), 10); if (bh <= h) active = btn; btn.classList.remove("current"); btn.removeAttribute("aria-current"); }); if (active) { active.classList.add("current"); active.setAttribute("aria-current", "page"); } } /* ---- per-slide sync ----------------------------------------------------- */ var DARK = ["section", "closing", "media"]; /* The dark fields split into two registers by luminance (see the on-dark token block in the theme). The footer and the viewport live outside the slide, so they cannot inherit the slide's register — deck.js mirrors it onto them as data-field="deep". Everything else dark stays on the bright defaults, which are safe on any field. */ function isDeepField(el) { if (!el) return false; if (el.classList.contains("section")) return el.classList.contains("navy"); return el.classList.contains("closing") || el.classList.contains("media"); } /* null on a paper slide, else the register the chrome over it must use. The live footer and the printed imprint both read it, so a slide can never be dark for one and light for the other. */ function fieldOf(el) { if (!el) return null; var attr = el.getAttribute("data-footer"); var dark = attr ? attr === "dark" : DARK.some(function (c) { return el.classList.contains(c); }); if (!dark) return null; return isDeepField(el) ? "deep" : "bright"; } function update() { var cur = Reveal.getCurrentSlide(); var h = Reveal.getIndices().h; var folio = countedFolio(h); if (counterCur) counterCur.textContent = pad2(folio.cur); if (counterTot) counterTot.textContent = pad2(folio.total); if (btnPrev) btnPrev.disabled = Reveal.isFirstSlide(); if (btnNext) btnNext.disabled = Reveal.isLastSlide(); var field = fieldOf(cur); var isDark = !!field; var isDeep = field === "deep"; var vp = document.querySelector(".reveal-viewport"); [footer, vp].forEach(function (el) { if (!el) return; el.classList.toggle(el === footer ? "on-dark" : "deck-dark", isDark); if (isDeep) el.setAttribute("data-field", "deep"); else el.removeAttribute("data-field"); }); // Running head: the current section on content slides; hidden on title pages. if (runhead) { var isHero = !!(cur && HERO.some(function (c) { return cur.classList.contains(c); })); runhead.hidden = isHero; if (!isHero && runSec) runSec.textContent = sectionLabelFor(h); } markCurrentTOC(); fitSlide(cur); animateCounts(cur); } /* ---- auto-fit: scale a slide's content down ONLY if it overflows the safe area (gold-rule clearance on hero slides; footer reserve below). Slides that already fit are never touched; an overflowing one is wrapped in an absolutely placed .fit box and scaled to fit. Runs once per slide, after webfonts settle so the measurement is real — and again (forced) when delayed content such as an image or a file embed resolves and changes the slide's height. Fitting is VISIBLE, not silent: every fitted slide carries data-fit with the applied scale; a scale below FIT_WARN logs a warning; below FIT_FAIL the slide is stamped data-fit-fail (a validation failure) unless the author explicitly allows it with a data-fit-allow attribute on the
    . ?no-fit / ?audit disables fitting entirely so raw overflow can be seen. --- */ var FIT_WARN = 0.95, FIT_FAIL = 0.90; var fitReady = !(document.fonts && document.fonts.ready); var FIT_SEEN = new WeakMap(); /* Layouts that centre content vertically: their .fit box spans the whole safe area and keeps the content centred while it scales. */ var CENTERED = ["cover", "section", "statement", "closing", "metric", "center", "balance"]; function unwrapFit(sec) { var fit = sec.querySelector(":scope > .fit"); if (!fit) return; while (fit.firstChild) sec.insertBefore(fit.firstChild, fit); sec.removeChild(fit); sec.removeAttribute("data-fit"); sec.removeAttribute("data-fit-fail"); } function fitSlide(sec, force) { if (!sec || !fitReady || NO_FIT) return; var cs = getComputedStyle(sec); var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; // The narrow chrome changes the slide's padding and footer reserve. Cache // the safe area's geometry, not just the slide: a rotation must not leave // an old fit in place, or leave a previously fitting slide overflowing. var geometry = [sec.clientWidth, sec.clientHeight, cs.paddingTop, cs.paddingBottom, cs.paddingLeft, cs.paddingRight, rem].join("|"); if (!force && FIT_SEEN.get(sec) === geometry) return; unwrapFit(sec); var padT = parseFloat(cs.paddingTop) || 0, padB = parseFloat(cs.paddingBottom) || 0; var padL = parseFloat(cs.paddingLeft) || 0, padR = parseFloat(cs.paddingRight) || 0; var hasRule = sec.classList.contains("section") || sec.classList.contains("closing"); // gold plate-rule at top var clearance = hasRule ? 1.8 * rem : 0; // breathing room below the gold plate-rule var kids = [].slice.call(sec.children).filter(function (c) { if (c.nodeName === "ASIDE" || (c.classList && c.classList.contains("fit")) || c.offsetParent === null) return false; var pos = getComputedStyle(c).position; // leave absolutely-placed decor (QR, media fill) in place return pos !== "absolute" && pos !== "fixed"; }); if (!kids.length) { FIT_SEEN.set(sec, geometry); return; } var topMost = Infinity, botMost = -Infinity, leftMost = Infinity, rightMost = -Infinity; kids.forEach(function (c) { topMost = Math.min(topMost, c.offsetTop); botMost = Math.max(botMost, c.offsetTop + c.offsetHeight); leftMost = Math.min(leftMost, c.offsetLeft); rightMost = Math.max(rightMost, c.offsetLeft + c.offsetWidth); }); var H = botMost - topMost, W = rightMost - leftMost; var boxTop = padT + clearance; var safeH = sec.clientHeight - boxTop - padB; var safeW = sec.clientWidth - padL - padR; var needFit = (H > safeH + 3) || (W > safeW + 3) || (hasRule && topMost < boxTop - 3); if (needFit && safeH > 40 && H > 0) { var k = Math.max(0.55, Math.min(1, safeH / H, W > 0 ? safeW / W : 1)); var centered = CENTERED.some(function (c) { return sec.classList.contains(c); }); var fit = document.createElement("div"); fit.className = "fit"; while (kids.length) fit.appendChild(kids.shift()); sec.insertBefore(fit, sec.firstChild); if (PRINT) { // Transforms apply after print fragmentation: text outside the // unscaled page can disappear even when its painted bounds fit. // Chromium's layout zoom scales before pagination instead. var printLeft = padL + (centered ? safeW * (1 - k) / 2 : 0); var printTop = boxTop + (centered ? safeH * (1 - k) / 2 : 0); fit.style.cssText = "position:absolute;top:" + (printTop / k) + "px;left:" + (printLeft / k) + "px;width:" + safeW + "px;margin:0;display:flex;flex-direction:column;zoom:" + k.toFixed(4) + ";" + (centered ? "height:" + safeH + "px;justify-content:center;" : ""); } else fit.style.cssText = "position:absolute;top:" + boxTop + "px;left:" + padL + "px;right:" + padR + "px;margin:0;display:flex;flex-direction:column;" + (centered ? "bottom:" + padB + "px;justify-content:center;transform-origin:center center;" : "transform-origin:top left;") + "transform:scale(" + k.toFixed(4) + ");"; sec.setAttribute("data-fit", k.toFixed(3)); if (k < FIT_FAIL && !sec.hasAttribute("data-fit-allow")) { sec.setAttribute("data-fit-fail", k.toFixed(3)); console.error("deck: slide " + slideRef(sec) + " auto-fitted to ×" + k.toFixed(3) + " (below the " + FIT_FAIL + " readability threshold). Trim the slide or add data-fit-allow."); } else if (k < FIT_WARN) { console.warn("deck: slide " + slideRef(sec) + " auto-fitted to ×" + k.toFixed(3) + " — consider trimming it."); } } FIT_SEEN.set(sec, geometry); if (checkModeUpdate) checkModeUpdate(); } function slideRef(sec) { var hSlides = Reveal.getHorizontalSlides ? Reveal.getHorizontalSlides() : []; var i = hSlides.indexOf(sec); var title = sec.querySelector("h1, h2, h3"); return "#" + (i >= 0 ? i + 1 : "?") + (title ? " (“" + title.textContent.trim().slice(0, 40) + "”)" : ""); } /* Re-fit a slide when late-loading content (images, embeds, iframes) changes its measured height after the first pass. */ function refitAfterLoad(el) { var sec = leafSlideFor(el); if (!sec || !fitReady) return; if ((FIT_SEEN && FIT_SEEN.has(sec)) || sec.querySelector(":scope > .fit")) fitSlide(sec, true); } /* ---- duotone filters (Move 2): inject the green/navy duotone SVG filters once so any deck can drop class="duotone" or a .plate figure and reference url(#duo-green) without hand-pasting the filter into every index.html. ---- */ function injectFilters() { if (document.getElementById("duo-green")) return; var DESAT = ''; var svg = elem( '' ); document.body.appendChild(svg); } /* ---- figures that count (Move 3): animate any [data-count] numeral up from zero when its slide arrives. Opt-in per element; the element's authored text is the exact final value (so "14,700+", "28.1M", ranges all render right). data-count="14700" target number data-count-decimals="1" fixed decimals during the roll (default 0) data-count-prefix / -suffix glued on each frame (e.g. "+", "M", "×") Runs on the theme's own motion tokens, and stills with the rest of the signature. ---------------------------------------------------------------- */ /* A CSS time token as a number of milliseconds ("560ms", "0s", "0.9s"). */ function msOf(value, fallback) { var v = String(value || "").trim(); var n = parseFloat(v); if (isNaN(n)) return fallback; return /ms$/.test(v) ? n : (/s$/.test(v) ? n * 1000 : n); } function animateCounts(slide) { if (!slide || typeof requestAnimationFrame !== "function") return; /* Counting up is the third movement of the signature the theme draws in CSS, so it asks the same switch whether to run at all: --draw-run resolves to 0s under print, the ?print-pdf preview, prefers-reduced-motion, and on a .no-draw deck or slide. Reading the property rather than re-deriving those four conditions here is what stops the JS half from stilling a different set of things than the CSS half — which is how the opt-out came to reach the bars but not the numbers standing on them. One condition CSS cannot express is added on top: in a hidden tab rAF is paused, so a rolling numeral would sit at 0 until the tab came back. */ var cs = getComputedStyle(slide); var still = msOf(cs.getPropertyValue("--draw-run"), 560) === 0 || document.hidden || PRINT; var dur = msOf(cs.getPropertyValue("--count"), 900); slide.querySelectorAll("[data-count]").forEach(function (el) { var target = parseFloat(el.getAttribute("data-count")); if (isNaN(target)) return; var dec = parseInt(el.getAttribute("data-count-decimals") || "0", 10) || 0; var pre = el.getAttribute("data-count-prefix") || ""; var suf = el.getAttribute("data-count-suffix") || ""; var done = el.getAttribute("data-count-text"); if (done === null) { done = el.textContent; el.setAttribute("data-count-text", done); } if (still) { if (el._countRAF) { cancelAnimationFrame(el._countRAF); el._countRAF = null; } if (el._countTO) { clearTimeout(el._countTO); el._countTO = null; } el.textContent = done; return; } if (el._countRAF) return; // a roll is already running — never reset it back to 0 var loc = document.documentElement.lang || "en"; // group digits in the deck's language function fmt(v) { return pre + v.toLocaleString(loc, { minimumFractionDigits: dec, maximumFractionDigits: dec }) + suf; } var t0 = null; function tick(ts) { if (t0 === null) t0 = ts; var p = Math.min(1, (ts - t0) / dur); el.textContent = fmt(target * (1 - Math.pow(1 - p, 3))); // ease-out-cubic if (p < 1) el._countRAF = requestAnimationFrame(tick); else { el.textContent = done; el._countRAF = null; if (el._countTO) { clearTimeout(el._countTO); el._countTO = null; } } } // Safety net: if frames stop arriving (tab hidden mid-roll), force the // final value so a numeral can never be left reading 0. setTimeout still // fires when rAF is throttled. el._countTO = setTimeout(function () { if (el._countRAF) { cancelAnimationFrame(el._countRAF); el._countRAF = null; } el.textContent = done; }, dur + 600); el.textContent = fmt(0); el._countRAF = requestAnimationFrame(tick); }); } /* ---- boot --------------------------------------------------------------- */ /* Chrome that doesn't depend on slide content — safe to run before init. */ function decorateChrome() { if (CFG.talkTitle && !document.title.trim()) document.title = CFG.talkTitle; injectFilters(); // make url(#duo-green) / url(#duo-navy) available deck-wide } /* Per-slide decoration: fill every [data-contact] slot from DECK_CONFIG. */ function decorateSlides() { document.querySelectorAll("[data-contact]").forEach(function (slot) { slot.classList.add("contact"); slot.innerHTML = contactHTML(); }); } /* Load any [data-embed-src] / [data-skill-src] panel from its vendored file and syntax-highlight it. Generic: optional data-error-message overrides the failure text, optional data-source-url adds a link to the original. Loading / success / failure states are exposed accessibly (aria-busy, role=status/alert), and the slide is re-fitted once the embed resolves. */ function loadFileEmbeds() { return Promise.all(Array.from(document.querySelectorAll("[data-embed-src], [data-skill-src]")).map(function (panel) { var code = panel.querySelector("code"); if (!code) return; var src = panel.getAttribute("data-embed-src") || panel.getAttribute("data-skill-src"); panel.setAttribute("aria-busy", "true"); panel.setAttribute("role", "status"); code.textContent = STR.embedLoading; return fetch(src) .then(function (r) { if (!r.ok) throw r.status; return r.text(); }) .then(function (text) { code.textContent = text; var hp = (typeof Reveal !== "undefined" && Reveal.getPlugin) ? Reveal.getPlugin("highlight") : null; var hl = window.hljs || (hp && hp.hljs); if (hl) { delete code.dataset.highlighted; code.classList.remove("hljs"); try { hl.highlightElement(code); } catch (e) {} } panel.removeAttribute("aria-busy"); // A loaded panel is a scroll region again (initScrollRegions). if (panel.matches(SCROLL_REGIONS)) panel.setAttribute("role", "region"); else panel.removeAttribute("role"); refitAfterLoad(panel); }) .catch(function () { var msg = panel.getAttribute("data-error-message") || STR.embedError; var url = panel.getAttribute("data-source-url"); code.textContent = msg + (url ? " — " + STR.embedSource + ": " + url : ""); panel.removeAttribute("aria-busy"); panel.setAttribute("role", "alert"); refitAfterLoad(panel); }); })); } /* ---- scroll regions: reachable from the keyboard ----------------------- A panel that scrolls its own content — .scroll-panel, or any per-deck scroller that opts in with data-scroll-region="its name" — was somewhere a mouse wheel could reach and a keyboard could not: nothing in it takes focus (WCAG 2.1.1). Each becomes a named, focusable region. While one has focus the vertical arrows scroll it rather than reaching reveal, but Left/Right, Page Up/Down and Space still drive the deck — those are what presenter remotes send, so a panel clicked mid-talk can never capture the clicker. Focus is released when the slide changes, so a panel the room can no longer see never keeps the keys. */ var SCROLL_REGIONS = ".scroll-panel, [data-scroll-region]"; function initScrollRegions() { var regions = document.querySelectorAll(".reveal .slides :is(" + SCROLL_REGIONS + ")"); if (!regions.length) return; regions.forEach(function (region) { if (!region.hasAttribute("tabindex")) region.setAttribute("tabindex", "0"); if (!region.hasAttribute("aria-label") && !region.hasAttribute("aria-labelledby")) { region.setAttribute("aria-label", region.getAttribute("data-scroll-region") || STR.scrollRegion); } // A file embed still loading holds role=status; it takes region on load. if (!region.hasAttribute("role")) region.setAttribute("role", "region"); region.addEventListener("keydown", function (e) { if (e.key === "ArrowUp" || e.key === "ArrowDown") e.stopPropagation(); }); }); Reveal.on("slidechanged", function () { var active = document.activeElement; if (active && active.matches && active.matches(SCROLL_REGIONS)) active.blur(); }); } /* ---- image lightbox: view a figure/screenshot full-screen ---------------- Fully keyboard-operable: every zoomable image is a focusable control that opens with Enter/Space; the dialog focuses its close button, traps Tab, navigates with the arrow keys, closes with Escape, and returns focus to the originating image. hidden/inert state mirrors the visual state. ------ */ /* Reveal 5.2+ ships a lightbox of its own, opened by data-preview-image / -video / -link on any element inside .slides — the way to show a screencast or an on-demand live site, which this viewer (images, one ) cannot. Two viewers must never bind one element: the enhancer below stops the click before it reaches reveal's delegated handler on .slides, so the native overlay would simply never open on an element it had also promoted. The author's opt-in wins, and the attribute may sit on the image or on anything around it, because that is what reveal's own closest() lookup reads. data-preview-link="false" is reveal's opt-out, so it hands the element back. */ var PREVIEW_OWNED = '[data-preview-image], [data-preview-video],' + ' [data-preview-link]:not([data-preview-link="false"])'; var lightbox, lbImg; function buildLightbox() { var seen = new Set(); var list = Array.from(document.querySelectorAll(".reveal .slides .shot, .reveal .slides .site-frame-view > img")) .map(function (trigger) { // A screenshot may use .shot on its frame rather than on the image. // Keep that frame as the control, but load and describe the image. return { trigger: trigger, img: trigger.matches("img") ? trigger : trigger.querySelector("img") }; }).filter(function (entry) { if (!entry.img || entry.img.closest(PREVIEW_OWNED) || seen.has(entry.img)) return false; seen.add(entry.img); return true; }); if (!list.length) return; lightbox = elem( '" ); document.body.appendChild(lightbox); setDialogHidden(lightbox, true); lbImg = lightbox.querySelector("img"); var lbCap = lightbox.querySelector("figcaption"); var lbClose = lightbox.querySelector(".lightbox-close"); var curIdx = -1, lbLastFocus = null, request = 0; lbCap.setAttribute("aria-live", "polite"); function showAt(i) { curIdx = (i + list.length) % list.length; var img = list[curIdx].img; var alt = img.getAttribute("alt") || ""; lbImg.setAttribute("alt", alt); lbImg.hidden = true; lbCap.textContent = STR.imageLoading; lbCap.style.display = ""; var current = ++request; loadImage(img).then(function () { if (current !== request) return; if (!img.naturalWidth) { lbCap.textContent = STR.imageUnavailable; return; } lbImg.src = imageSource(img); lbImg.hidden = false; lbCap.textContent = alt; }); if (!lightbox.classList.contains("open")) { lbLastFocus = list[curIdx].trigger; lightbox.classList.add("open"); setDialogHidden(lightbox, false); focusWhenVisible(lbClose); } } function closeLightbox() { lightbox.classList.remove("open"); setDialogHidden(lightbox, true); lbImg.removeAttribute("src"); curIdx = -1; request++; // Return focus to the image that opened the viewer. if (lbLastFocus && lbLastFocus.focus) lbLastFocus.focus(); lbLastFocus = null; } list.forEach(function (entry, i) { var trigger = entry.trigger; trigger.classList.add("is-zoomable"); trigger.setAttribute("tabindex", "0"); trigger.setAttribute("role", "button"); var alt = entry.img.getAttribute("alt") || ""; trigger.setAttribute("aria-label", STR.imageView + (alt ? ": " + alt : "")); trigger.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); showAt(i); }); trigger.addEventListener("keydown", function (e) { if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") { e.preventDefault(); e.stopPropagation(); showAt(i); } }); }); lightbox.addEventListener("click", closeLightbox); // backdrop, image, or close button document.addEventListener("keydown", function (e) { if (!lightbox.classList.contains("open")) return; // Tab stays available so the dialog is keyboard-traversable — trap it inside. if (e.key === "Tab") { e.preventDefault(); e.stopPropagation(); lbClose.focus(); return; } e.stopPropagation(); // never drive the deck behind the lightbox if (e.key === "Escape") { e.preventDefault(); closeLightbox(); } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); showAt(curIdx + 1); } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); showAt(curIdx - 1); } }, true); } /* ---- live iframes: lazy-load + offline fallback ------------------------- Every .site-frame-view iframe is converted to reveal's data-src form so it loads only when its slide becomes visible (and unloads after). A useful fallback stays until the user opens the live surface or an owned app sends its declared readiness message. Load events are not evidence. Permission to embed a site is not evidence that it loaded. In particular, legacy data-frame-trusted declarations must not hide a saved screenshot when the network is unavailable. Sites without a readiness handshake keep their fallback until the presenter explicitly opens the live view. */ function lazifyFrames() { document.querySelectorAll(".reveal .slides .site-frame-view > iframe[src]").forEach(function (f) { if (!f.hasAttribute("data-src")) f.setAttribute("data-src", f.getAttribute("src")); f.removeAttribute("src"); }); } function initFrameFallbacks() { var frames = document.querySelectorAll(".reveal .slides iframe"); if (!frames.length) return; var fallbackTimers = new WeakMap(); // A load event also fires for blocked/error documents. Only an explicit // application handshake establishes readiness; the user may opt into an // unverified live surface through the overlay's button. window.addEventListener("message", function (event) { frames.forEach(function (f) { var type = f.getAttribute("data-ready-message"); var url = new URL(f.getAttribute("data-src") || f.src, location.href); if (type && event.source === f.contentWindow && event.origin === url.origin && event.data && event.data.type === type && leafSlideFor(f) === Reveal.getCurrentSlide()) { f.setAttribute("data-frame-ready", ""); clearTimeout(fallbackTimers.get(f)); hideFallback(f); } }); }); function fallbackFor(f) { var view = f.parentElement; var fb = view.querySelector(".frame-fallback, .viz-fallback, .amrc-fallback"); if (!fb) { var openLink = f.closest(".site-frame") && f.closest(".site-frame").querySelector(".site-frame-open"); var href = f.getAttribute("data-fallback-href") || (openLink && openLink.getAttribute("href")) || f.getAttribute("data-src") || ""; fb = elem('

    '); fb.querySelector("p").textContent = STR.frameNetwork; if (href) { var link = document.createElement("a"); link.href = href; link.target = "_blank"; link.rel = "noopener"; link.textContent = STR.frameOpen + " ↗"; fb.appendChild(link); } var show = document.createElement("button"); show.type = "button"; show.className = "frame-show"; show.textContent = STR.frameShow; show.addEventListener("click", function () { clearTimeout(fallbackTimers.get(f)); fb.hidden = true; f.style.visibility = ""; if (f.getAttribute("data-src")) f.src = f.getAttribute("data-src"); f.focus(); }); fb.appendChild(show); fb.hidden = true; view.appendChild(fb); } return fb; } function hideFallback(f) { var fb = f.parentElement.querySelector(".frame-fallback, .viz-fallback, .amrc-fallback"); if (fb) fb.hidden = true; f.style.visibility = ""; } function watchCurrent() { frames.forEach(function (f) { clearTimeout(fallbackTimers.get(f)); fallbackTimers.delete(f); f.removeAttribute("data-frame-ready"); }); var cur = Reveal.getCurrentSlide(); if (!cur) return; cur.querySelectorAll("iframe").forEach(function (f) { var fallback = fallbackFor(f); fallback.hidden = false; if (fallback.matches("img")) f.style.visibility = "hidden"; var timer = setTimeout(function () { fallbackTimers.delete(f); if (!f.hasAttribute("data-frame-ready") && Reveal.getCurrentSlide() === cur) { var text = fallback.querySelector("p"); if (text && fallback.classList.contains("frame-fallback")) text.textContent = STR.frameUnavailable; } }, 8000); fallbackTimers.set(f, timer); }); } Reveal.on("slidechanged", watchCurrent); if (!Reveal.isPrintView()) watchCurrent(); } /* ---- syntax highlighting (plugin-independent) --------------------------- */ /* Highlight every
     with the slim global from
         shared/highlight.min.js (~40 KB), which is what every deck loads instead of
         reveal's 921 KB bundled highlight plugin — that plugin is not vendored and
         the engine registers no highlighter. A deck with no code slides simply
         drops the script tag and this becomes a no-op. The data-highlighted guard
         is hljs's own mark, so a second pass never re-highlights a block. */
      function highlightAll() {
        var hl = window.hljs;
        if (!hl) return;
        document.querySelectorAll(".reveal .slides pre code").forEach(function (code) {
          if (code.dataset.highlighted) return;
          try { hl.highlightElement(code); } catch (e) {}
        });
      }
    
      /* ---- dev-only slide check (?check): outline any slide whose content spills
         past the fixed 1280×720 canvas, and make auto-fitting VISIBLE — the banner
         shows the applied data-fit scale for the current slide, amber below the
         warning threshold, red below the failure threshold. Combine with ?no-fit
         (or use ?audit, which implies both) to see authored overflow before any
         scaling is applied. ------------------------------------------------------- */
      var checkModeUpdate = null;
      function enableCheckMode() {
        var banner = elem('
    Slide overflows 1280×720
    '); banner.hidden = true; document.body.appendChild(banner); function check() { var s = Reveal.getCurrentSlide(); if (!s) return; var fitted = s.hasAttribute("data-fit"); var k = fitted ? parseFloat(s.getAttribute("data-fit")) : 1; // A fitted slide's layout boxes are unscaled (transforms don't change // scrollHeight), so the raw overflow test only applies to unfitted slides. var over = !fitted && (s.scrollHeight > s.clientHeight + 1 || s.scrollWidth > s.clientWidth + 1); var fail = s.hasAttribute("data-fit-fail") || (fitted && k < FIT_FAIL && !s.hasAttribute("data-fit-allow")); var warn = fitted && k < FIT_WARN; var msg = "", color = "#c0392b"; if (over) msg = NO_FIT ? "Authored overflow (fitting disabled)" : "Slide overflows 1280×720"; else if (fitted) { msg = "Auto-fitted ×" + k.toFixed(3) + (fail ? " — below " + FIT_FAIL : warn ? " — below " + FIT_WARN : ""); color = fail ? "#c0392b" : warn ? "#b9770e" : "#1e8449"; } var outline = over || fail ? "#c0392b" : warn ? "#b9770e" : ""; s.style.outline = outline ? "3px solid " + outline : ""; s.style.outlineOffset = outline ? "-3px" : ""; banner.textContent = msg; banner.style.background = color; banner.hidden = !msg; } checkModeUpdate = check; Reveal.on("slidechanged", check); check(); } function init() { var reveal = document.querySelector(".reveal"); decorateChrome(); lazifyFrames(); // live iframes load only when their slide becomes visible leafSlides().forEach(function (slide, i) { if (slide.id) return; var label = slide.dataset.toc || (slide.querySelector("h1,h2") || {}).textContent || "slide"; var id = label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "slide"; var baseId = id, suffix = 2; while (document.getElementById(id)) id = baseId + "-" + suffix++; slide.id = id; }); window.DeckRuntime.ready = Reveal.initialize({ width: 1280, height: 720, margin: 0, minScale: 0.2, maxScale: 2.0, // Never switch to reveal 6's scroll view on narrow screens: the theme is // designed around a scaled 1280×720 canvas, and the automatic scroll mode // (≤435 px wide) clips two-column slides on portrait phones. scrollActivationWidth: null, center: false, hash: true, controls: false, progress: true, slideNumber: false, transition: CFG.transition || "fade", transitionSpeed: "default", autoAnimate: !motionPreference.matches, defaultTiming: CFG.defaultTiming || null, totalTime: CFG.totalTime || null, backgroundTransition: "fade", overview: true, touch: true, keyboard: true, // PDF export (?print-pdf): one printed page per slide. Reveal's default // is Infinity, so any slide even 1px over the page height spilled onto // extra pages — the "one slide, several pages" bug. The house style is a // fixed 1280×720 screen per slide, so a hard cap of 1 is always right. pdfSeparateFragments: false, pdfMaxPagesPerSlide: 1, plugins: revealPlugins() }).then(function () { decorateSlides(); // fill [data-contact] slots buildFooter(reveal); buildRunhead(reveal); buildTOC(reveal); fileEmbedsReady = loadFileEmbeds(); initScrollRegions(); buildLightbox(); initFrameFallbacks(); highlightAll(); // highlight code via global hljs (works without the bundled plugin) update(); // Late-loading media (lazy data-src images, embeds) can change a slide's // height after the first fit pass — re-fit the slide when they arrive. // Images only: iframes are reloaded by the browser whenever the fit // wrapper reparents them, so refitting on iframe load would loop. document.querySelector(".slides").addEventListener("load", function (e) { if (e.target && e.target.nodeName === "IMG") refitAfterLoad(e.target); }, true); // If the tab hides mid-roll, rAF freezes; snap any counting numeral to its // final value so none is ever left reading a partial count (or 0). document.addEventListener("visibilitychange", function () { if (!document.hidden) return; document.querySelectorAll("[data-count]").forEach(function (el) { if (el._countRAF) { cancelAnimationFrame(el._countRAF); el._countRAF = null; } if (el._countTO) { clearTimeout(el._countTO); el._countTO = null; } var done = el.getAttribute("data-count-text"); if (done !== null) el.textContent = done; }); }); if (CHECK_MODE) enableCheckMode(); motionPreference.addEventListener("change", function () { Reveal.configure({ autoAnimate: !motionPreference.matches }); }); // Defensive relayout: recompute the scale once the window and webfonts // have settled, in case the deck initialised before it had real size. window.addEventListener("load", function () { Reveal.layout(); fitReady = true; fitSlide(Reveal.getCurrentSlide()); }); if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { Reveal.layout(); fitReady = true; fitSlide(Reveal.getCurrentSlide()); }); // Reveal's resize event only fires when its scale changes. A CSS // breakpoint can change our safe area even at the same scale, so listen // to the viewport itself. Other slides re-check their geometry on entry. var resizeFitFrame = null; window.addEventListener("resize", function () { if (PRINT) return; if (resizeFitFrame !== null) cancelAnimationFrame(resizeFitFrame); resizeFitFrame = requestAnimationFrame(function () { resizeFitFrame = null; Reveal.layout(); fitSlide(Reveal.getCurrentSlide()); }); }); // PDF export (?print-pdf): every slide prints, so every slide needs the // overflow fit — not just the current one. Re-fit them all (force: the // pass may re-run once fonts settle or after reveal builds its // .pdf-page layout, whichever lands last). if (PRINT) { var fitAllPages = function () { if (!fitReady || !document.querySelector(".reveal .pdf-page")) return; settleSlides(leafSlides()); }; Reveal.on("pdf-ready", fitAllPages); if (document.fonts && document.fonts.ready) document.fonts.ready.then(fitAllPages); } Reveal.addKeyBinding({ keyCode: 84, key: "T", description: "Table of contents" }, toggleTOC); // While the TOC dialog is open, all keys act on the dialog (capture phase, // before reveal's own handler): Esc closes, arrows move, Tab is trapped. document.addEventListener("keydown", tocKeydown, true); }); Reveal.on("slidechanged", update); Reveal.on("overviewshown", function () { if (footer) footer.style.opacity = "0"; }); Reveal.on("overviewhidden", function () { if (footer) footer.style.opacity = ""; }); } function revealPlugins() { var p = []; if (window.RevealNotes) p.push(RevealNotes); if (window.RevealZoom) p.push(RevealZoom); if (window.RevealSearch) p.push(RevealSearch); return p; } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init(); })();