1(function () { 2 // Per-issue config is provided by the page shell as window.FlipReaderConfig. 3 // Falls back to the legacy LeadershipReaderAddons object + the original 4 // /flip/test spread-model constants so the prototype keeps working unchanged. 5 const CONFIG = window.FlipReaderConfig || window.LeadershipReaderAddons || {}; 6 const BASE_PATH = cleanConfigText(CONFIG.basePath) || "/flip/test"; 7 const ASSETS_PATH = cleanConfigText(CONFIG.assetsPath) || `${BASE_PATH}/indesign_export_test/publication-web-resources`; 8 const ARTICLE_INDEX_URL = cleanConfigText(CONFIG.articleIndexUrl) || `${BASE_PATH}/indesign_export_test/article-index.json`; 9 const PAGE_MODEL = (cleanConfigText(CONFIG.pageModel) || "spread").toLowerCase(); 10 const PAGE_WIDTH = Number(CONFIG.pageWidth) > 0 ? Math.round(Number(CONFIG.pageWidth)) : 652; 11 const PAGE_HEIGHT = Number(CONFIG.pageHeight) > 0 ? Math.round(Number(CONFIG.pageHeight)) : 780; 12 const SPREAD_WIDTH = Number(CONFIG.spreadWidth) > 0 ? Math.round(Number(CONFIG.spreadWidth)) : PAGE_WIDTH * 2; 13 const SPREAD_ASPECT = SPREAD_WIDTH / PAGE_HEIGHT; 14 const SOURCE_COUNT = Number(CONFIG.sourceCount) > 0 ? Math.round(Number(CONFIG.sourceCount)) : 35; 15 // The print edition doesn't count cover pages but the reader does, so the 16 // number shown to the reader (and spoken by the AI) can differ from the reader 17 // page index. pageNumberOffset maps reader page -> printed page (e.g. -2). 18 const PAGE_NUMBER_OFFSET = Number.isFinite(Number(CONFIG.pageNumberOffset)) ? Math.round(Number(CONFIG.pageNumberOffset)) : 0; 19 const FLIPPING_TIME = Number(CONFIG.flippingTime) > 0 ? Math.round(Number(CONFIG.flippingTime)) : 650; 20 const PAGES = buildPages(); 21 const PAGE_COUNT = PAGES.length; 22 const PRINTED_TOTAL = PAGE_COUNT + PAGE_NUMBER_OFFSET; 23 24 function printedPage(readerPage) { 25 return Number(readerPage) + PAGE_NUMBER_OFFSET; 26 } 27 28 function pageLabelText(readerPage) { 29 const printed = printedPage(readerPage); 30 if (printed < 1) return "Cover"; 31 return `Page ${printed} / ${PRINTED_TOTAL}`; 32 } 33 const readerStage = document.querySelector(".reader-stage"); 34 const pageFlipRoot = document.getElementById("flipBook"); 35 const statusNode = document.getElementById("readerStatus"); 36 const pageLabel = document.getElementById("readerPageLabel"); 37 const pageScrubber = document.getElementById("pageScrubber"); 38 const prevButton = document.getElementById("prevPage"); 39 const nextButton = document.getElementById("nextPage"); 40 const articleToggle = document.getElementById("articleToggle"); 41 const searchToggle = document.getElementById("searchToggle"); 42 const fullscreenToggle = document.getElementById("fullscreenToggle"); 43 const routeNote = document.getElementById("routeNote"); 44 const readerBackdrop = document.getElementById("readerBackdrop"); 45 const searchDrawer = document.getElementById("searchDrawer"); 46 const searchInput = document.getElementById("readerSearchInput"); 47 const searchResults = document.getElementById("readerSearchResults"); 48 const innovationDock = document.getElementById("innovationDock"); 49 const innovationPanel = document.getElementById("innovationPanel"); 50 const innovationTitle = document.getElementById("innovationTitle"); 51 const innovationBody = document.getElementById("innovationBody"); 52 const innovationLaunch = document.getElementById("innovationLaunch"); 53 const innovationSpeak = document.getElementById("innovationSpeak"); 54 const innovationStop = document.getElementById("innovationStop"); 55 const runtimeAddonsConfig = CONFIG; 56 let contentsPagePromise = null; 57 let innovations = Array.isArray(window.FlipReaderInnovations) 58 ? [...window.FlipReaderInnovations] 59 : (Array.isArray(window.LeadershipReaderInnovations) 60 ? [...window.LeadershipReaderInnovations] 61 : []); 62 let pageFlip = null; 63 let articleIndex = []; 64 let currentArticle = null; 65 let activeInnovation = null; 66 let runtimeAddonsPayload = null; 67 let prevEdgeZone = null; 68 let nextEdgeZone = null; 69 let edgeDrag = null; 70 let lastTrackedReaderPage = null; 71 let activeReaderPage = 1; 72 let renderCurlPoint = null; 73 const pageChangeCallbacks = []; 74 const loadedScriptPromises = new Map(); 75 const pageFrames = []; 76 77 function clamp(value, min, max) { 78 return Math.max(min, Math.min(max, value)); 79 } 80 81 function cleanConfigText(value) { 82 return value === undefined || value === null ? "" : String(value).trim(); 83 } 84 85 function queryValue(names) { 86 if (runtimeAddonsConfig.allowQueryOverride === false) return ""; 87 const params = new URLSearchParams(window.location.search);
88 const keys = Array.isArray(names) ? names : [names]; 89 for (const key of keys) { 90 const value = cleanConfigText(params.get(key)); 91 if (value) return value; 92 } 93 return ""; 94 } 95 96 function configValue(key, queryNames) { 97 return queryValue(queryNames || key) || cleanConfigText(runtimeAddonsConfig[key]); 98 } 99 100 function configNumber(key, queryNames) { 101 const value = Number(configValue(key, queryNames)); 102 return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; 103 } 104 105 function cssPixels(value) { 106 const number = Number.parseFloat(value); 107 return Number.isFinite(number) ? number : 0; 108 } 109 110 function buildPages() { 111 // Single-page export: each source HTML file is exactly one reader page, 112 // shown whole (no clip). Used when the InDesign/In5 export is per-page. 113 if (PAGE_MODEL === "single") { 114 const pages = []; 115 for (let source = 1; source <= SOURCE_COUNT; source += 1) { 116 pages.push({ 117 number: pages.length + 1, 118 source, 119 offsetX: 0, 120 sourceWidth: PAGE_WIDTH 121 }); 122 } 123 return pages; 124 } 125 // Spread export (default): cover + back are single pages; every middle 126 // source file is a two-page spread split into a left and right reader page. 127 const pages = [{ 128 number: 1, 129 source: 1, 130 offsetX: 0, 131 sourceWidth: PAGE_WIDTH 132 }]; 133 for (let source = 2; source <= SOURCE_COUNT - 1; source += 1) { 134 pages.push({ 135 number: pages.length + 1, 136 source, 137 offsetX: 0, 138 sourceWidth: SPREAD_WIDTH 139 }); 140 pages.push({ 141 number: pages.length + 1, 142 source, 143 offsetX: PAGE_WIDTH, 144 sourceWidth: SPREAD_WIDTH 145 }); 146 } 147 pages.push({ 148 number: pages.length + 1, 149 source: SOURCE_COUNT, 150 offsetX: 0, 151 sourceWidth: PAGE_WIDTH 152 }); 153 return pages; 154 } 155 156 function sourceFile(sourceNumber) { 157 return sourceNumber === 1 158 ? `${ASSETS_PATH}/html/publication.html` 159 : `${ASSETS_PATH}/html/publication-${sourceNumber - 1}.html`; 160 } 161 162 function sourceNumberFromHref(href, baseUrl) { 163 try { 164 if (!href || href.trim().startsWith("#")) return null; 165 const url = new URL(href, baseUrl || window.location.href); 166 if (url.origin !== window.location.origin) return null; 167 const filename = url.pathname.split("/").pop(); 168 if (filename === "publication.html") return 1; 169 const match = filename && filename.match(/^publication-(\d+)\.html$/); 170 return match ? clamp(Number(match[1]) + 1, 1, SOURCE_COUNT) : null; 171 } catch (_error) { 172 return null; 173 } 174 } 175 176 function firstPageForSource(sourceNumber) { 177 const page = PAGES.find(pageDef => pageDef.source === sourceNumber); 178 return page ? page.number : 1; 179 } 180 181 function compactInnovationLabel(item) { 182 // Audio interviews are presented to readers as finished editorial 183 // podcasts. Keep the public label consistent even when a legacy runtime 184 // add-on still supplies "Interview" / "Media" metadata. 185 if (item && item.type === "interview") return "Podcast"; 186 const labels = { 187 ai: "Ask", 188 chatbot: "Chat", 189 tldr: "Summary", 190 readAloud: "Audio" 191 }; 192 const fallback = item.label || item.title || "Open"; 193 return item.compactLabel || labels[item.type] || fallback.split(/\s+/)[0] || "Open"; 194 } 195 196 // Classify each media element once by the publisher's intent: 197 // - "content": shipped with a controller (controls attribute) â a video the 198 // reader is meant to watch with sound. Keep controls + audio, no loop, no 199 // autoplay; pause when its page flips out of view. 200 // - "ambient": background/decorative (no controls) â muted, looping, plays 201 // only while its spread is visible. 202 function frameMediaKind(media) { 203 if (media.dataset.flipMediaKind) return media.dataset.flipMediaKind; 204 const isVideo = media.tagName.toLowerCase() === "video"; 205 const kind = isVideo && media.hasAttribute("controls") ? "content" : "ambient"; 206 media.dataset.flipMediaKind = kind; 207 return kind; 208 } 209 210 function prepareFrameMedia(media) {
211 const kind = frameMediaKind(media); 212 media.setAttribute("playsinline", ""); 213 media.setAttribute("webkit-playsinline", ""); 214 // InDesign page-load autoplay is always disabled; the reader drives playback. 215 media.removeAttribute("autoplay"); 216 media.autoplay = false; 217 218 if (kind === "content") { 219 // Apply once so repeated visibility syncs never fight the viewer's own 220 // play/pause/volume choices. 221 if (media.dataset.flipMediaPrepared === "1") return; 222 media.dataset.flipMediaPrepared = "1"; 223 media.controls = true; 224 media.muted = false; 225 media.defaultMuted = false; 226 media.loop = false; 227 media.removeAttribute("loop"); 228 // A content video must never start on its own. The InDesign export tries 229 // to auto-play it (native autoplay attribute AND its on-load script), so 230 // removing the attribute is not enough â we hard-block any play() that is 231 // not triggered by a real user gesture on the element. 232 const markUser = () => { media.dataset.userActivated = "1"; }; 233 media.addEventListener("pointerdown", markUser, true); 234 media.addEventListener("keydown", markUser, true); 235 media.addEventListener("play", () => { 236 if (media.dataset.userActivated !== "1") { 237 try { media.pause(); } catch (_e) {} 238 } 239 }); 240 try { media.pause(); media.currentTime = 0; } catch (_e) {} 241 return; 242 } 243 244 media.controls = false; 245 media.muted = true; 246 media.defaultMuted = true; 247 media.loop = true; 248 media.volume = 0; 249 media.removeAttribute("controls"); 250 media.setAttribute("loop", ""); 251 media.setAttribute("muted", ""); 252 } 253 254 function mediaIntersectsPage(media, pageElement) { 255 const offsetX = Number(pageElement.dataset.offsetX || 0); 256 const rect = media.getBoundingClientRect(); 257 return rect.right > offsetX && rect.left < offsetX + PAGE_WIDTH; 258 } 259 260 function visiblePageNumbers(pageNumber) { 261 if (pageFlip && typeof pageFlip.getOrientation === "function" && pageFlip.getOrientation() === "portrait") { 262 return [pageNumber]; 263 } 264 if (pageNumber <= 1 || pageNumber >= PAGE_COUNT) return [pageNumber]; 265 const leftPage = pageNumber % 2 === 0 ? pageNumber : pageNumber - 1; 266 return [leftPage, Math.min(leftPage + 1, PAGE_COUNT)]; 267 } 268 269 function setMediaPlayback(media, shouldPlay) { 270 prepareFrameMedia(media); 271 272 // Content videos are user-driven: never auto-play, but pause them when the 273 // page flips out of view so audio does not bleed across spreads. 274 if (frameMediaKind(media) === "content") { 275 if (!shouldPlay && !media.paused) media.pause(); 276 return; 277 } 278 279 if (media.tagName.toLowerCase() !== "video" || !shouldPlay) { 280 if (!media.paused) media.pause(); 281 return; 282 } 283 const playPromise = media.play(); 284 if (playPromise && playPromise.catch) { 285 playPromise.catch(() => {}); 286 } 287 } 288 289 function syncFrameMedia(pageNumber) { 290 const visiblePages = new Set(visiblePageNumbers(clamp(Number(pageNumber), 1, PAGE_COUNT))); 291 pageFlipRoot.querySelectorAll(".page").forEach(page => { 292 const isVisiblePage = visiblePages.has(Number(page.dataset.page || 0)); 293 const frame = page.querySelector("iframe"); 294 try { 295 const doc = frame && frame.contentDocument; 296 if (!doc) return; 297 doc.querySelectorAll("video,audio").forEach(media => { 298 setMediaPlayback(media, isVisiblePage && mediaIntersectsPage(media, page)); 299 }); 300 } catch (_error) { 301 // Same-origin frames are expected, but keep the reader resilient. 302 } 303 }); 304 } 305 306 function prepareFrameLinks(frame) { 307 const doc = frame.contentDocument; 308 if (!doc) return; 309 doc.querySelectorAll("a[href]").forEach(anchor => { 310 if (anchor.dataset.readerLinkBound === "1") return; 311 anchor.dataset.readerLinkBound = "1"; 312 const href = anchor.getAttribute("href") || ""; 313 const sourceNumber = sourceNumberFromHref(href, frame.src); 314 anchor.style.cursor = "pointer"; 315 if (sourceNumber) { 316 anchor.removeAttribute("target"); 317 anchor.addEventListener("click", event => { 318 event.preventDefault(); 319 event.stopPropagation(); 320 currentArticle = null; 321 goToPage(firstPageForSource(sourceNumber), { animate: true }); 322 }); 323 } else { 324 anchor.target = anchor.target || "_blank"; 325 anchor.rel = "noopener"; 326 anchor.addEventListener("click", event => { 327 event.stopPropagation(); 328 }); 329 } 330 }); 331 } 332 333 // InDesign pages carry many large images that all request at once; under a 334 // real connection some get dropped and show as broken. Re-fetch any image that 335 // errors (cache-busted, with backoff, capped) so the page self-heals. 336 function reloadBrokenFrameImages(doc) { 337 doc.querySelectorAll("img").forEach(img => { 338 if (img.dataset.flipImgBound === "1") return; 339 img.dataset.flipImgBound = "1"; 340 const original = img.getAttribute("src"); 341 if (!original) return; 342 const retry = () => {
343 const n = Number(img.dataset.flipImgRetry || 0); 344 if (n >= 3) return; 345 img.dataset.flipImgRetry = String(n + 1); 346 const sep = original.indexOf("?") === -1 ? "?" : "&"; 347 img.src = original + sep + "_r=" + (n + 1); 348 }; 349 img.addEventListener("error", () => { 350 window.setTimeout(retry, 600 * (Number(img.dataset.flipImgRetry || 0) + 1)); 351 }); 352 // Catch images that already failed before this handler was attached. 353 if (img.complete && img.naturalWidth === 0) { 354 window.setTimeout(retry, 300); 355 } 356 }); 357 } 358 359 // Read the @font-face rules the page declares so we can force stalled faces 360 // to finish loading (parallel to reloadBrokenFrameImages for <img>). 361 function collectFrameFontFaces(doc) { 362 const faces = []; 363 Array.from(doc.styleSheets || []).forEach(sheet => { 364 let rules; 365 try { rules = sheet.cssRules; } catch (_e) { return; } // cross-origin sheet 366 if (!rules) return; 367 Array.from(rules).forEach(rule => { 368 if (rule.type !== 5 /* CSSRule.FONT_FACE_RULE */) return; 369 const family = (rule.style.getPropertyValue("font-family") || "").replace(/["']/g, "").trim(); 370 const srcRaw = rule.style.getPropertyValue("src") || ""; 371 const m = srcRaw.match(/url\(\s*['"]?([^'")]+\.(?:otf|woff2|woff|ttf))['"]?\s*\)/i); 372 if (!family || !m) return; 373 let url; 374 try { url = new URL(m[1], sheet.href || doc.baseURI).href; } catch (_e) { return; } 375 faces.push({ 376 family, url, 377 weight: (rule.style.getPropertyValue("font-weight") || "normal").trim() || "normal", 378 style: (rule.style.getPropertyValue("font-style") || "normal").trim() || "normal", 379 stretch: (rule.style.getPropertyValue("font-stretch") || "normal").trim() || "normal", 380 // Preserve cross-platform metric overrides â a retry FontFace built 381 // without these would render with the native (platform-specific) 382 // metrics and re-introduce the drop-cap misalignment on some pages. 383 display: (rule.style.getPropertyValue("font-display") || "block").trim() || "block", 384 ascentOverride: (rule.style.getPropertyValue("ascent-override") || "").trim(), 385 descentOverride: (rule.style.getPropertyValue("descent-override") || "").trim(), 386 lineGapOverride: (rule.style.getPropertyValue("line-gap-override") || "").trim() 387 }); 388 }); 389 }); 390 return faces; 391 } 392 393 function fontKey(family, weight, style) { 394 return (family || "").replace(/["']/g, "").trim().toLowerCase() + "|" + 395 (weight || "normal") + "|" + (style || "normal"); 396 } 397 398 // In5 exports declare each face with a generic fallback (e.g. `sans-serif`). 399 // Under the reader's image load a font request can drop, so the fallback 400 // sticks â visible as a large drop cap sitting ~one line low. When a face 401 // actually fails (the FontFaceSet `loadingerror` event â the analog of 402 // img.onerror) re-request it with a cache-busted URL over a fresh connection, 403 // capped and backed off. A single late sweep also refetches any face still 404 // unloaded long after the page settled, to catch silent stalls. Both share a 405 // per-face retry cap, and neither fires when fonts loaded normally â so 406 // healthy pages issue zero extra requests. 407 function ensureFrameFonts(doc) { 408 try { 409 if (!doc.fonts || doc.__flipFontsForced) return; 410 doc.__flipFontsForced = true; 411 const win = doc.defaultView; 412 if (!win || !win.FontFace) return; 413 414 const infoByKey = {}; 415 const infoByFamily = {}; 416 collectFrameFontFaces(doc).forEach(info => { 417 infoByKey[fontKey(info.family, info.weight, info.style)] = info; 418 const fam = info.family.toLowerCase(); 419 if (!infoByFamily[fam]) infoByFamily[fam] = info; 420 }); 421 const retries = {}; 422 423 const refetch = info => { 424 if (!info) return; 425 const key = fontKey(info.family, info.weight, info.style); 426 const n = retries[key] || 0; 427 if (n >= 3) return; 428 retries[key] = n + 1; 429 const bust = info.url + (info.url.indexOf("?") === -1 ? "?" : "&") + "_rf=" + (n + 1); 430 const descriptors = { weight: info.weight, style: info.style, stretch: info.stretch, display: info.display || "block" }; 431 if (info.ascentOverride) descriptors.ascentOverride = info.ascentOverride; 432 if (info.descentOverride) descriptors.descentOverride = info.descentOverride; 433 if (info.lineGapOverride) descriptors.lineGapOverride = info.lineGapOverride; 434 let ff; 435 try { 436 ff = new win.FontFace(info.family, 'url("' + bust + '")', descriptors); 437 } catch (_e) { return; } 438 ff.load().then(loaded => { try { doc.fonts.add(loaded); } catch (_e) {} }) 439 .catch(() => { window.setTimeout(() => refetch(info), 900 * (n + 1)); }); 440 }; 441 442 const infoForFace = face => { 443 const fam = (face && face.family || "").replace(/["']/g, ""); 444 return infoByKey[fontKey(fam, face.weight, face.style)] || infoByFamily[fam.toLowerCase()]; 445 }; 446 447 if (doc.fonts.addEventListener) { 448 doc.fonts.addEventListener("loadingerror", event => { 449 const faces = (event && event.fontfaces) || [];
450 faces.forEach(face => refetch(infoForFace(face))); 451 }); 452 } 453 454 // Safety sweep well past a normal cold load: refetch only faces that are 455 // still unloaded (check() === false), so healthy pages stay silent. 456 window.setTimeout(() => { 457 Object.keys(infoByKey).forEach(key => { 458 const info = infoByKey[key]; 459 const spec = (info.style !== "normal" ? info.style + " " : "") + 460 (info.weight !== "normal" ? info.weight + " " : "") + 461 '48px "' + info.family + '"'; 462 let ok = true; 463 try { ok = doc.fonts.check(spec); } catch (_e) { ok = true; } 464 if (!ok) refetch(info); 465 }); 466 }, 10000); 467 } catch (_error) { 468 // Font forcing is best-effort; never break the reader. 469 } 470 } 471 472 function suppressFrameScroll(frame) { 473 try { 474 const doc = frame.contentDocument; 475 if (!doc) return; 476 doc.documentElement.style.overflow = "hidden"; 477 if (doc.body) doc.body.style.overflow = "hidden"; 478 doc.querySelectorAll("[data-mediaOnPageLoadActions]").forEach(element => { 479 element.removeAttribute("data-mediaOnPageLoadActions"); 480 }); 481 reloadBrokenFrameImages(doc); 482 ensureFrameFonts(doc); 483 prepareFrameLinks(frame); 484 syncFrameMedia(currentPageNumber()); 485 } catch (_error) { 486 // Same-origin frames are expected, but keep the reader resilient. 487 } 488 } 489 490 async function loadArticleIndex() { 491 try { 492 const response = await fetch(ARTICLE_INDEX_URL, { cache: "no-store" }); 493 articleIndex = response.ok ? await response.json() : []; 494 } catch (_error) { 495 articleIndex = []; 496 } 497 } 498 499 function runtimeAddonsUrl() { 500 const endpoint = configValue("endpoint", "addons_endpoint"); 501 if (!endpoint) return null; 502 503 // Add-ons (chatbots/audio) can live under a different edition id than the 504 // issue's content edition: addonsEditionId overrides publicationEditionId 505 // for the reader_addons fetch when set. 506 const publicationEditionId = configNumber("addonsEditionId", ["addons_edition"]) 507 || configNumber("publicationEditionId", [ 508 "publication_edition_id", 509 "edition" 510 ]); 511 const experience = configValue("conversationalAiExperience", [ 512 "addons_experience", 513 "conversational_ai_experience", 514 "experience" 515 ]); 516 517 if (!publicationEditionId && !experience) return null; 518 519 const url = new URL(endpoint, window.location.origin); 520 if (publicationEditionId) url.searchParams.set("edition", String(publicationEditionId)); 521 if (experience) url.searchParams.set("experience", experience); 522 url.searchParams.set("issue", configValue("issueKey", "addons_issue") || "magazine"); 523 url.searchParams.set("page_count", String(configNumber("pageCount", "addons_page_count") || PAGE_COUNT)); 524 return url.toString(); 525 } 526 527 function isExternalInnovation(item) { 528 return item && ["ai", "chatbot", "interview"].includes(item.type); 529 } 530 531 function shouldReplaceStaticExternalTools() { 532 return runtimeAddonsConfig.replaceStaticExternalTools !== false; 533 } 534 535 function mergeInnovations(items) { 536 const incoming = Array.isArray(items) ? items.filter(item => item && item.id) : []; 537 if (!incoming.length) { 538 if (shouldReplaceStaticExternalTools()) { 539 innovations = innovations.filter(item => !isExternalInnovation(item)); 540 } 541 return; 542 } 543 544 const byId = new Map(); 545 const existing = shouldReplaceStaticExternalTools() 546 ? innovations.filter(item => !isExternalInnovation(item)) 547 : innovations; 548 549 existing.forEach(item => byId.set(item.id, item)); 550 incoming.forEach(item => byId.set(item.id, item)); 551 innovations = Array.from(byId.values()); 552 } 553 554 async function loadRuntimeInnovations() { 555 const url = runtimeAddonsUrl(); 556 if (!url) return null; 557 558 try { 559 const response = await fetch(url, { 560 cache: "no-store", 561 credentials: "omit", 562 headers: { Accept: "application/json" } 563 }); 564 const payload = await response.json(); 565 if (!response.ok || payload.ok === false) { 566 throw new Error(payload.error || "runtime_addons_failed"); 567 } 568 runtimeAddonsPayload = payload; 569 window.LeadershipReaderAddonsPayload = payload; 570 mergeInnovations(payload.addons || []); 571 renderInnovationDock(currentPageNumber()); 572 return payload; 573 } catch (error) { 574 console.warn("[LeadershipReader] Runtime add-ons unavailable", error); 575 return null; 576 } 577 } 578 579 function loadScriptOnce(src, id, configure) {
580 const key = id || src; 581 if (loadedScriptPromises.has(key)) return loadedScriptPromises.get(key); 582 583 const existing = id ? document.getElementById(id) : null; 584 if (existing) { 585 const promise = Promise.resolve(existing); 586 loadedScriptPromises.set(key, promise); 587 return promise; 588 } 589 590 const promise = new Promise((resolve, reject) => { 591 const script = document.createElement("script"); 592 if (id) script.id = id; 593 script.async = true; 594 script.src = src; 595 if (typeof configure === "function") configure(script); 596 script.onload = () => resolve(script); 597 script.onerror = () => { 598 loadedScriptPromises.delete(key); 599 script.remove(); 600 reject(new Error(`Failed to load ${src}`)); 601 }; 602 document.head.append(script); 603 }); 604 loadedScriptPromises.set(key, promise); 605 return promise; 606 } 607 608 function articlePublicSlug(article) { 609 return String(article && (article.publicSlug || article.slug) || "").trim().toLowerCase(); 610 } 611 612 function articleMatchesSlug(article, slug) {
613 const candidate = String(slug || "").trim().toLowerCase(); 614 return candidate !== "" && [article && article.slug, article && article.publicSlug] 615 .some(value => String(value || "").trim().toLowerCase() === candidate); 616 } 617 618 function articleUrl(article) { 619 return `${BASE_PATH}/article/${articlePublicSlug(article)}/`; 620 } 621 622 function pageUrl(pageNumber) { 623 // URLs use the printed page number the reader sees. Cover/front-matter pages 624 // (printed < 1) have no printed number, so they resolve to the base path. 625 const printed = printedPage(pageNumber); 626 return printed >= 1 ? `${BASE_PATH}/page/${printed}/` : `${BASE_PATH}/`; 627 } 628 629 function isLocalTrackingHost() { 630 return ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname); 631 } 632 633 function readerDeviceType() { 634 return /(android|bb\d+|meego|mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino)/i.test(navigator.userAgent) 635 ? "mobile" 636 : "desktop"; 637 } 638 639 function trackingValue(params, name) { 640 const value = params.get(name); 641 return value && value.trim() !== "" ? value : null; 642 } 643 644 function trackReaderPage(pageNumber) { 645 const tracking = window.LeadershipReaderTracking || {}; 646 const page = Number(pageNumber); 647 if (!Number.isFinite(page) || page <= 0 || lastTrackedReaderPage === page) return; 648 649 if (tracking.initialPageTracked && lastTrackedReaderPage === null && Number(tracking.initialPage) === page) { 650 lastTrackedReaderPage = page; 651 return; 652 } 653 654 lastTrackedReaderPage = page; 655 window.dataLayer = window.dataLayer || []; 656 window.dataLayer.push({ 657 event: "reader_page_view", 658 page_number: page, 659 page_location: window.location.href, 660 page_title: document.title 661 }); 662 663 if (!tracking.endpoint || isLocalTrackingHost()) return; 664 665 const params = new URLSearchParams(window.location.search); 666 const payload = { 667 utm_source: trackingValue(params, "utm_source"), 668 utm_campaign: trackingValue(params, "utm_campaign"), 669 utm_content: trackingValue(params, "utm_content"), 670 utm_term: trackingValue(params, "utm_term"), 671 utm_medium: trackingValue(params, "utm_medium"), 672 eid: trackingValue(params, "eid"), 673 location: window.location.href, 674 page, 675 user_agent: navigator.userAgent, 676 device_type: readerDeviceType() 677 }; 678 679 const body = JSON.stringify(payload); 680 if (navigator.sendBeacon) { 681 try { 682 const blob = new Blob([body], { type: "application/json" }); 683 if (navigator.sendBeacon(tracking.endpoint, blob)) return; 684 } catch (_error) { 685 // Fall back to fetch below. 686 } 687 } 688 689 fetch(tracking.endpoint, { 690 method: "POST", 691 mode: "cors", 692 keepalive: true, 693 headers: { "Content-Type": "application/json" }, 694 body 695 }).catch(() => {}); 696 } 697 698 function articleFromLocation() { 699 const articleMatch = window.location.pathname.match(/\/article\/([^/]+)\/?$/); 700 if (!articleMatch) return null; 701 const slug = decodeURIComponent(articleMatch[1]); 702 return articleIndex.find(article => articleMatchesSlug(article, slug)) || null; 703 } 704 705 function articlesForPage(pageNumber) { 706 return articleIndex.filter(article => (article.pages || []).includes(pageNumber)); 707 } 708 709 function articleOwnsStatusPage(article, pageNumber) { 710 const pages = article.pages || []; 711 const startPage = Number(article.startPage || pages[0]); 712 return pages.includes(pageNumber) || (startPage > 1 && startPage % 2 === 1 && pageNumber === startPage - 1); 713 } 714 715 function initialPageFromLocation() { 716 // /page/ and /spread/ URLs carry the printed page number; /article/ resolves 717 // via slug to an internal reader page. Convert printed -> reader here. 718 currentArticle = articleFromLocation(); 719 if (currentArticle) return clamp(Number(currentArticle.startPage || currentArticle.pages[0]), 1, PAGE_COUNT); 720 const pageMatch = window.location.pathname.match(/\/page\/(\d+)\/?$/); 721 if (pageMatch) return clamp(printedToReaderPage(Number(pageMatch[1])), 1, PAGE_COUNT);
722 const spreadMatch = window.location.pathname.match(/\/spread\/(\d+)-(\d+)\/?$/); 723 if (spreadMatch) return clamp(printedToReaderPage(Number(spreadMatch[1])), 1, PAGE_COUNT); 724 const queryPage = Number(new URLSearchParams(window.location.search).get("page")); 725 return Number.isFinite(queryPage) && queryPage > 0 ? clamp(printedToReaderPage(queryPage), 1, PAGE_COUNT) : 1; 726 } 727 728 // The canonical internal reader page (iframe index space), tracked directly so 729 // it never depends on the displayed label â which shows printed page numbers. 730 function currentPageNumber() { 731 return activeReaderPage; 732 } 733 734 // Printed page (what the reader sees) <-> reader page (internal) conversions. 735 function printedToReaderPage(printed) { 736 return Number(printed) - PAGE_NUMBER_OFFSET; 737 } 738 739 function formatPageRange(pages) { 740 if (!Array.isArray(pages) || pages.length === 0) return "Pages not set"; 741 const sorted = [...pages].map(printedPage).filter(p => p >= 1).sort((a, b) => a - b); 742 if (sorted.length === 0) return "Cover"; 743 return sorted.length === 1 744 ? `Page ${sorted[0]}` 745 : `Pages ${sorted[0]}-${sorted[sorted.length - 1]}`; 746 } 747 748 function updateStatus(pageIndex) { 749 const pageNumber = clamp(Number(pageIndex) + 1, 1, PAGE_COUNT); 750 preparePageFrames(pageNumber); 751 activeReaderPage = pageNumber; 752 const pageArticles = articlesForPage(pageNumber); 753 const activeArticle = currentArticle && articleOwnsStatusPage(currentArticle, pageNumber) 754 ? currentArticle 755 : pageArticles[0]; 756 const activeUrl = activeArticle ? articleUrl(activeArticle) : pageUrl(pageNumber); 757 statusNode.textContent = pageLabelText(pageNumber); 758 if (pageLabel) pageLabel.textContent = pageLabelText(pageNumber); 759 if (pageScrubber && document.activeElement !== pageScrubber) pageScrubber.value = String(pageNumber); 760 prevButton.disabled = pageNumber <= 1; 761 nextButton.disabled = pageNumber >= PAGE_COUNT; 762 updateEdgeTurnZones(pageNumber); 763 routeNote.textContent = activeArticle ? activeArticle.title : ""; 764 renderInnovationDock(pageNumber); 765 syncFrameMedia(pageNumber); 766 if (!searchDrawer.hidden) renderSearchResults(searchInput.value); 767 if (window.location.pathname !== activeUrl) { 768 window.history.replaceState({ page: pageNumber }, "", pageUrl(pageNumber)); 769 } 770 trackReaderPage(pageNumber); 771 notifyPageChange(pageNumber, activeArticle); 772 } 773 774 function notifyPageChange(pageNumber, activeArticle) { 775 const detail = { 776 page: pageNumber, 777 pageCount: PAGE_COUNT, 778 article: activeArticle || articlesForPage(pageNumber)[0] || null, 779 addons: innovationsForPage(pageNumber) 780 }; 781 if (window.nav) window.nav.page = pageNumber; 782 window.dispatchEvent(new CustomEvent("leadership:reader-page-change", { detail })); 783 pageChangeCallbacks.forEach(callback => { 784 try { 785 callback(detail); 786 } catch (error) { 787 console.warn("[LeadershipReader] page change callback failed", error); 788 } 789 }); 790 } 791 792 function updateFrameScales() { 793 requestAnimationFrame(() => { 794 // Derive ONE scale from the book's display HEIGHT ÷ the native page height, 795 // and apply it to every page. The book height is fixed by fitBookToStage, 796 // identical in portrait and landscape, and constant throughout a flip â so 797 // it never produces the transient values that per-page widths do: 0 for an 798 // off-screen page (â flashes small mid-turn) or a briefly-larger width 799 // mid-rotation / at a portraitâlandscape switch (â occasionally snaps 800 // zoomed-in). Uniform scale keeps every page correct before it flips in. 801 const bookHeight = pageFlipRoot.getBoundingClientRect().height || 802 Number(pageFlipRoot.dataset.fitHeight || 0); 803 if (bookHeight <= 1 || PAGE_HEIGHT <= 1) return; // not laid out â keep prior scales 804 const scale = bookHeight / PAGE_HEIGHT; 805 pageFlipRoot.querySelectorAll(".page").forEach(page => { 806 const clip = page.querySelector(".page-clip"); 807 if (clip) clip.style.transform = `scale(${scale})`; 808 }); 809 }); 810 } 811 812 function fitBookToStage() { 813 if (!readerStage || !pageFlipRoot) return; 814 const rect = readerStage.getBoundingClientRect(); 815 const style = window.getComputedStyle(readerStage); 816 const availableWidth = Math.max(320, rect.width - cssPixels(style.paddingLeft) - cssPixels(style.paddingRight)); 817 const availableHeight = Math.max(360, rect.height - cssPixels(style.paddingTop) - cssPixels(style.paddingBottom)); 818 let targetWidth = availableWidth; 819 let targetHeight = targetWidth / SPREAD_ASPECT; 820 if (targetHeight > availableHeight) { 821 targetHeight = availableHeight; 822 targetWidth = targetHeight * SPREAD_ASPECT; 823 } 824 pageFlipRoot.style.width = `${Math.floor(targetWidth)}px`; 825 pageFlipRoot.style.height = `${Math.floor(targetHeight)}px`; 826 pageFlipRoot.style.setProperty("--reader-book-width", `${Math.floor(targetWidth)}px`);
827 pageFlipRoot.dataset.fitWidth = String(Math.floor(targetWidth)); 828 pageFlipRoot.dataset.fitHeight = String(Math.floor(targetHeight)); 829 positionEdgeTurnZones(targetWidth, targetHeight, availableWidth, availableHeight, style); 830 } 831 832 function createEdgeTurnZones() { 833 if (!readerStage || prevEdgeZone || nextEdgeZone) return; 834 prevEdgeZone = document.createElement("button"); 835 nextEdgeZone = document.createElement("button"); 836 prevEdgeZone.type = "button"; 837 nextEdgeZone.type = "button"; 838 prevEdgeZone.className = "page-turn-zone page-turn-prev-zone"; 839 nextEdgeZone.className = "page-turn-zone page-turn-next-zone"; 840 prevEdgeZone.dataset.direction = "prev"; 841 nextEdgeZone.dataset.direction = "next"; 842 prevEdgeZone.setAttribute("aria-label", "Previous page edge"); 843 nextEdgeZone.setAttribute("aria-label", "Next page edge"); 844 prevEdgeZone.title = "Previous page"; 845 nextEdgeZone.title = "Next page"; 846 [prevEdgeZone, nextEdgeZone].forEach(zone => { 847 zone.addEventListener("pointerdown", beginEdgeTurn); 848 zone.addEventListener("click", handleEdgeKeyboardClick); 849 }); 850 readerStage.append(prevEdgeZone, nextEdgeZone); 851 } 852 853 function flipEdge(direction) { 854 if (!pageFlip) return; 855 if (direction === "prev" && prevEdgeZone && !prevEdgeZone.disabled) { 856 pageFlip.flipPrev(); 857 } else if (direction === "next" && nextEdgeZone && !nextEdgeZone.disabled) { 858 pageFlip.flipNext(); 859 } 860 } 861 862 function pointForFlipEvent(event) { 863 const ui = pageFlip && typeof pageFlip.getUI === "function" ? pageFlip.getUI() : null; 864 const element = ui && typeof ui.getDistElement === "function" ? ui.getDistElement() : pageFlipRoot; 865 const rect = element.getBoundingClientRect(); 866 return { 867 x: event.clientX - rect.left, 868 y: event.clientY - rect.top 869 }; 870 } 871 872 function beginEdgeTurn(event) { 873 const zone = event.currentTarget; 874 const direction = zone.dataset.direction; 875 if (!pageFlip || zone.disabled || event.button > 0) return; 876 event.preventDefault(); 877 event.stopPropagation(); 878 edgeDrag = { 879 direction, 880 pointerId: event.pointerId, 881 startX: event.clientX, 882 startY: event.clientY, 883 moved: false, 884 zone 885 }; 886 pageFlipRoot.classList.add("is-edge-turning"); 887 if (typeof zone.setPointerCapture === "function") { 888 try { 889 zone.setPointerCapture(event.pointerId); 890 } catch (_error) { 891 // Pointer capture can fail if the pointer was already released. 892 } 893 } 894 pageFlip.startUserTouch(pointForFlipEvent(event));
895 document.addEventListener("pointermove", continueEdgeTurn, true); 896 document.addEventListener("pointerup", finishEdgeTurn, true); 897 document.addEventListener("pointercancel", cancelEdgeTurn, true); 898 window.addEventListener("blur", cancelActiveEdgeTurn); 899 } 900 901 function continueEdgeTurn(event) { 902 if (!edgeDrag || event.pointerId !== edgeDrag.pointerId) return; 903 event.preventDefault(); 904 event.stopPropagation(); 905 const dx = event.clientX - edgeDrag.startX; 906 const dy = event.clientY - edgeDrag.startY; 907 if (Math.sqrt((dx * dx) + (dy * dy)) > 5) edgeDrag.moved = true; 908 pageFlip.userMove(pointForFlipEvent(event), false); 909 } 910 911 function finishEdgeTurn(event) { 912 if (!edgeDrag || event.pointerId !== edgeDrag.pointerId) return; 913 event.preventDefault(); 914 event.stopPropagation(); 915 const drag = edgeDrag; 916 try { 917 if (drag.moved) { 918 pageFlip.userStop(pointForFlipEvent(event), false); 919 } else { 920 pageFlip.userStop(pointForFlipEvent(event), true); 921 flipEdge(drag.direction); 922 } 923 } finally { 924 cleanupEdgeTurn(event); 925 } 926 } 927 928 function cancelEdgeTurn(event) { 929 if (!edgeDrag || event.pointerId !== edgeDrag.pointerId) return; 930 event.preventDefault(); 931 event.stopPropagation(); 932 try { 933 if (pageFlip && typeof pageFlip.userStop === "function") { 934 pageFlip.userStop(pointForFlipEvent(event), true); 935 } 936 } finally { 937 cleanupEdgeTurn(event); 938 } 939 } 940 941 function cancelActiveEdgeTurn() { 942 if (!edgeDrag) return; 943 cleanupEdgeTurn(); 944 } 945 946 function clearEdgeTurnMode() { 947 pageFlipRoot.classList.remove("is-edge-turning"); 948 } 949 950 function cleanupEdgeTurn(event) { 951 const drag = edgeDrag; 952 edgeDrag = null; 953 clearEdgeTurnMode(); 954 window.setTimeout(clearEdgeTurnMode, 0); 955 window.setTimeout(clearEdgeTurnMode, 700); 956 if (!drag) return;
957 document.removeEventListener("pointermove", continueEdgeTurn, true); 958 document.removeEventListener("pointerup", finishEdgeTurn, true); 959 document.removeEventListener("pointercancel", cancelEdgeTurn, true); 960 window.removeEventListener("blur", cancelActiveEdgeTurn); 961 if (typeof drag.zone.releasePointerCapture === "function" && event) { 962 try { 963 drag.zone.releasePointerCapture(event.pointerId); 964 } catch (_error) { 965 // Pointer capture may already have been released by the browser. 966 } 967 } 968 } 969 970 function handleEdgeKeyboardClick(event) { 971 event.preventDefault(); 972 event.stopPropagation(); 973 if (event.detail !== 0) return; 974 flipEdge(event.currentTarget.dataset.direction); 975 } 976 977 function syncEdgeTurnModeForState(event) { 978 if (!event || event.data !== "read") return; 979 if (edgeDrag) cleanupEdgeTurn(); 980 clearEdgeTurnMode(); 981 } 982 983 function positionEdgeTurnZones(targetWidth, targetHeight, availableWidth, availableHeight, stageStyle) { 984 if (!prevEdgeZone || !nextEdgeZone) return; 985 const edgeWidth = clamp(Math.round(targetWidth * 0.055), 42, 84); 986 const left = cssPixels(stageStyle.paddingLeft) + Math.max(0, (availableWidth - targetWidth) / 2); 987 const top = cssPixels(stageStyle.paddingTop) + Math.max(0, (availableHeight - targetHeight) / 2); 988 [prevEdgeZone, nextEdgeZone].forEach(zone => { 989 zone.style.top = `${Math.floor(top)}px`; 990 zone.style.width = `${Math.floor(edgeWidth)}px`; 991 zone.style.height = `${Math.floor(targetHeight)}px`; 992 }); 993 prevEdgeZone.style.left = `${Math.floor(left)}px`; 994 nextEdgeZone.style.left = `${Math.floor(left + targetWidth - edgeWidth)}px`; 995 } 996 997 function updateEdgeTurnZones(pageNumber) { 998 if (!prevEdgeZone || !nextEdgeZone) return; 999 prevEdgeZone.disabled = pageNumber <= 1; 1000 nextEdgeZone.disabled = pageNumber >= PAGE_COUNT; 1001 } 1002 1003 function resizeReader() { 1004 fitBookToStage(); 1005 if (pageFlip && typeof pageFlip.update === "function") { 1006 pageFlip.update(); 1007 } 1008 updateFrameScales(); 1009 window.setTimeout(updateFrameScales, 250); 1010 } 1011 1012 function scheduleResizeReader() { 1013 resizeReader(); 1014 [100, 350, 800].forEach(delay => { 1015 window.setTimeout(resizeReader, delay); 1016 }); 1017 } 1018 1019 function createPages() { 1020 pageFlipRoot.replaceChildren(); 1021 pageFrames.length = 0; 1022 PAGES.forEach(pageDef => { 1023 const page = document.createElement("div"); 1024 page.className = "page"; 1025 page.dataset.page = String(pageDef.number); 1026 page.dataset.source = String(pageDef.source); 1027 page.dataset.offsetX = String(pageDef.offsetX); 1028 const clip = document.createElement("div"); 1029 clip.className = "page-clip"; 1030 const frame = document.createElement("iframe"); 1031 frame.title = `Magazine page ${pageDef.number}`; 1032 frame.loading = "eager"; 1033 frame.scrolling = "no"; 1034 frame.style.width = `${pageDef.sourceWidth}px`; 1035 frame.style.transform = `translateX(-${pageDef.offsetX}px)`; 1036 frame.addEventListener("load", () => { 1037 if (!frame.getAttribute("src") || frame.contentDocument?.URL === "about:blank") return; 1038 frame.dataset.readerLoaded = "1"; 1039 suppressFrameScroll(frame); 1040 }); 1041 frame.dataset.readerSrc = sourceFile(pageDef.source); 1042 pageFrames.push(frame); 1043 clip.append(frame); 1044 page.append(clip); 1045 pageFlipRoot.append(page); 1046 }); 1047 preparePageFrames(initialPageFromLocation()); 1048 } 1049 1050 function preparePageFrames(pageNumber) { 1051 // StPageFlip stacks every iframe in the viewport, defeating native lazy 1052 // loading. Keep pagination placeholders, but request only nearby spreads. 1053 const page = clamp(Number(pageNumber) || 1, 1, PAGE_COUNT); 1054 const spreadStart = page === 1 ? 0 : page - (page % 2); 1055 // Request the visible pair first, then the next and previous pair. Keep 1056 // references because PageFlip may clone/reorder DOM nodes during turns. 1057 for (const offset of [0, 1, 2, 3, -2, -1]) { 1058 const number = spreadStart + offset; 1059 if (number < 1 || number > PAGE_COUNT) continue; 1060 const frame = pageFrames[number - 1]; 1061 if (frame && !frame.getAttribute("src")) frame.src = frame.dataset.readerSrc; 1062 } 1063 } 1064 1065 function isSameVisibleSpread(pageIndex) { 1066 if (!pageFlip || typeof pageFlip.getPageCollection !== "function") return false;
1067 const collection = pageFlip.getPageCollection(); 1068 return collection && 1069 typeof collection.getCurrentSpreadIndex === "function" && 1070 typeof collection.getSpreadIndexByPage === "function" && 1071 collection.getCurrentSpreadIndex() === collection.getSpreadIndexByPage(pageIndex); 1072 } 1073 1074 function holdRenderPageCurl(xRatio, yRatio) { 1075 if (!CONFIG.renderMode || !pageFlip || typeof pageFlip.getBoundsRect !== "function") return false; 1076 const bounds = pageFlip.getBoundsRect(); 1077 const x = clamp(Number(xRatio) || 0.75, 0.5, 0.95); 1078 const y = clamp(Number(yRatio) || 0.75, 0.5, 0.95); 1079 const start = { 1080 x: bounds.left + bounds.width - 2, 1081 y: bounds.top + bounds.height - 2 1082 }; 1083 renderCurlPoint = { 1084 x: bounds.left + bounds.width * x, 1085 y: bounds.top + bounds.height * y 1086 }; 1087 pageFlip.startUserTouch(start); 1088 pageFlip.userMove(renderCurlPoint, false); 1089 return pageFlip.getState(); 1090 } 1091 1092 function releaseRenderPageCurl() { 1093 if (!CONFIG.renderMode || !pageFlip || !renderCurlPoint) return false; 1094 pageFlip.userStop(renderCurlPoint, false); 1095 renderCurlPoint = null; 1096 return true; 1097 } 1098 1099 function goToPage(pageNumber, options) { 1100 const index = clamp(pageNumber, 1, PAGE_COUNT) - 1; 1101 preparePageFrames(index + 1); 1102 const shouldAnimate = options && options.animate; 1103 const corner = options && options.corner === "bottom" ? "bottom" : "top"; 1104 if (shouldAnimate && pageFlip && typeof pageFlip.flip === "function") { 1105 if (index !== pageFlip.getCurrentPageIndex() && !isSameVisibleSpread(index)) { 1106 pageFlip.flip(index, corner); 1107 } else { 1108 if (pageFlip && typeof pageFlip.turnToPage === "function") { 1109 pageFlip.turnToPage(index); 1110 } 1111 updateStatus(index); 1112 } 1113 } else if (pageFlip && typeof pageFlip.turnToPage === "function") { 1114 pageFlip.turnToPage(index); 1115 updateStatus(index); 1116 } else if (pageFlip && typeof pageFlip.flip === "function") { 1117 pageFlip.flip(index); 1118 } else { 1119 updateStatus(index); 1120 } 1121 updateFrameScales(); 1122 } 1123 1124 function goToArticle(article) { 1125 currentArticle = article; 1126 window.history.pushState({ article: article.slug }, "", articleUrl(article)); 1127 goToPage(Number(article.startPage || article.pages[0] || 1), { animate: true }); 1128 closePanels(); 1129 } 1130 1131 function goToArticleSlug(articleSlug) { 1132 const slug = String(articleSlug || "").trim().toLowerCase(); 1133 if (!slug) return false; 1134 const article = articleIndex.find(item => articleMatchesSlug(item, slug)); 1135 if (!article) return false; 1136 goToArticle(article); 1137 return true; 1138 } 1139 1140 function matchArticle(article, term) { 1141 if (!term) return true; 1142 const haystack = `${article.title || ""} ${article.section || ""} ${(article.pages || []).join(" ")}`.toLowerCase(); 1143 return haystack.includes(term.toLowerCase()); 1144 } 1145 1146 function renderArticleList(target, filterTerm) { 1147 if (!target) return; 1148 const pageNumber = currentPageNumber(); 1149 const activeSlug = currentArticle && articleOwnsStatusPage(currentArticle, pageNumber) 1150 ? currentArticle.slug 1151 : (articlesForPage(pageNumber)[0] || {}).slug; 1152 const matches = articleIndex.filter(article => matchArticle(article, filterTerm || "")); 1153 target.replaceChildren(); 1154 if (!matches.length) { 1155 const empty = document.createElement("p"); 1156 empty.className = "empty-state"; 1157 empty.textContent = "No matching articles."; 1158 target.append(empty); 1159 return; 1160 } 1161 matches.forEach(article => { 1162 const button = document.createElement("button"); 1163 button.type = "button"; 1164 button.className = `drawer-item${article.slug === activeSlug ? " is-active" : ""}`; 1165 button.innerHTML = `<strong></strong><span></span>`; 1166 button.querySelector("strong").textContent = article.title; 1167 button.querySelector("span").textContent = `${article.section || "Article"} - ${formatPageRange(article.pages)}`; 1168 button.addEventListener("click", () => goToArticle(article)); 1169 target.append(button); 1170 }); 1171 } 1172
1173 function renderSearchResults(term) { 1174 renderArticleList(searchResults, term || ""); 1175 } 1176 1177 function normalizeContentsText(value) { 1178 return String(value || "") 1179 .toLowerCase() 1180 .replace(/[^a-z0-9]+/g, " ") 1181 .replace(/\s+/g, " ") 1182 .trim(); 1183 } 1184 1185 function articleTitleMatchesText(title, pageText) { 1186 const normalized = normalizeContentsText(title); 1187 if (!normalized) return false; 1188 if (normalized.length >= 6 && pageText.includes(normalized)) return true; 1189 const words = normalized.split(" ").filter(word => word.length > 3); 1190 if (words.length < 2) return false; 1191 const hits = words.filter(word => pageText.includes(word)).length; 1192 return hits >= 2 && (hits / words.length) >= 0.65; 1193 } 1194 1195 async function sourceText(sourceNumber) { 1196 try { 1197 const response = await fetch(sourceFile(sourceNumber), { cache: "force-cache" }); 1198 if (!response.ok) return ""; 1199 const html = await response.text(); 1200 const doc = new DOMParser().parseFromString(html, "text/html"); 1201 return normalizeContentsText(doc.body ? doc.body.textContent : html); 1202 } catch (_error) { 1203 return ""; 1204 } 1205 } 1206 1207 async function detectContentsPage() { 1208 const configuredPage = configNumber("contentsPage", ["contents_page"]); 1209 if (configuredPage) return clamp(configuredPage, 1, PAGE_COUNT); 1210 1211 const configuredSource = configNumber("contentsSource", ["contents_source"]); 1212 if (configuredSource) return firstPageForSource(clamp(configuredSource, 1, SOURCE_COUNT)); 1213 1214 // Older issues predate contentsPage metadata. Inspect front matter only and 1215 // mirror the builder's TOC signal: a page claiming at least three article 1216 // headings beats a generic occurrence of the word "contents". 1217 const sourceNumbers = Array.from( 1218 { length: Math.min(SOURCE_COUNT, 24) }, 1219 (_unused, index) => index + 1 1220 ); 1221 const texts = await Promise.all(sourceNumbers.map(sourceText)); 1222 let bestClaimed = null; 1223 let bestExplicit = null; 1224 1225 texts.forEach((text, index) => { 1226 if (!text) return; 1227 const claims = articleIndex.filter(article => articleTitleMatchesText(article.title, text)).length; 1228 const candidate = { source: sourceNumbers[index], claims }; 1229 if (claims >= 3 && (!bestClaimed || claims > bestClaimed.claims)) { 1230 bestClaimed = candidate; 1231 } 1232 if (!bestExplicit && /(?:^| )contents(?: |$)/.test(text)) { 1233 bestExplicit = candidate; 1234 } 1235 }); 1236 1237 const match = bestClaimed || bestExplicit; 1238 return match ? firstPageForSource(match.source) : 1; 1239 } 1240 1241 async function goToContents() { 1242 articleToggle.disabled = true; 1243 articleToggle.setAttribute("aria-busy", "true"); 1244 try { 1245 if (!contentsPagePromise) contentsPagePromise = detectContentsPage(); 1246 currentArticle = null; 1247 goToPage(await contentsPagePromise, { animate: true }); 1248 } finally { 1249 articleToggle.disabled = false; 1250 articleToggle.removeAttribute("aria-busy"); 1251 } 1252 } 1253 1254 function isInnovationForPage(item, pageNumber) { 1255 if (Array.isArray(item.pageList)) return item.pageList.includes(pageNumber); 1256 if (!Array.isArray(item.pages)) return false; 1257 if (item.pages.length === 2) { 1258 return pageNumber >= item.pages[0] && pageNumber <= item.pages[1]; 1259 } 1260 return item.pages.includes(pageNumber); 1261 } 1262 1263 function innovationsForPage(pageNumber) { 1264 return innovations.filter(item => isInnovationForPage(item, pageNumber)); 1265 } 1266 1267 // Human labels for the card "kind" line; the article/title carries the meaning. 1268 function innovationKindLabel(item) { 1269 const kinds = { tldr: "Summary", interview: "Podcast", chatbot: "Chat", readAloud: "Listen" }; 1270 return kinds[item.type] || item.label || "Tool"; 1271 } 1272 1273 function advertiserChatbotLabel(item) { 1274 const action = item && item.action && typeof item.action === "object" ? item.action : {}; 1275 const chatbot = item && item.chatbot && typeof item.chatbot === "object" ? item.chatbot : {}; 1276 const rawName = action.name || chatbot.name || item.client || item.title || ""; 1277 const advertiser = String(rawName).replace(/\s+(?:chat)?bot$/i, "").trim(); 1278 return advertiser 1279 ? `Engage with our ${advertiser} bot for more information` 1280 : "Engage with our advertiser bot for more information"; 1281 } 1282 1283 function createChatbotBubbleIcon() { 1284 const namespace = "http://www.w3.org/2000/svg"; 1285 const icon = document.createElementNS(namespace, "svg"); 1286 icon.setAttribute("class", "k chatbot-bubble-icon"); 1287 icon.setAttribute("viewBox", "0 0 24 24"); 1288 icon.setAttribute("aria-hidden", "true"); 1289 icon.setAttribute("focusable", "false"); 1290 const outline = document.createElementNS(namespace, "path"); 1291 outline.setAttribute("d", "M21 11.5c0 4.7-4 8.5-9 8.5-1.4 0-2.8-.3-4-.8L3 21l1.8-4.3A8.2 8.2 0 0 1 3 11.5C3 6.8 7 3 12 3s9 3.8 9 8.5Z"); 1292 icon.append(outline); 1293 return icon; 1294 } 1295 1296 function renderInnovationDock(pageNumber) { 1297 if (!innovationDock) return; 1298 innovationDock.replaceChildren(); 1299 const items = innovationsForPage(pageNumber); 1300 1301 // The conversational AI is the rail's single hero action, not a dock card. 1302 const askHero = document.getElementById("askHero"); 1303 const aiItem = items.find(item => item.type === "ai") || null; 1304 if (askHero) { 1305 askHero.hidden = !aiItem; 1306 if (aiItem) { 1307 askHero.title = "This mag wants to talk to you"; 1308 askHero.setAttribute("aria-label", "This mag wants to talk to you"); 1309 askHero.onclick = () => openInnovation(aiItem); 1310 } 1311 } 1312 1313 items.filter(item => item.type !== "ai").forEach(item => { 1314 const button = document.createElement("button"); 1315 button.type = "button"; 1316 button.className = `innovation-pill innovation-${item.type || "tool"}`;
1317 const kind = item.type === "chatbot" 1318 ? createChatbotBubbleIcon() 1319 : document.createElement("span"); 1320 if (item.type !== "chatbot") { 1321 kind.className = "k"; 1322 kind.textContent = innovationKindLabel(item); 1323 } 1324 const name = document.createElement("span"); 1325 name.className = "t"; 1326 const buttonLabel = item.type === "chatbot" 1327 ? advertiserChatbotLabel(item) 1328 : item.title || item.label || "Open"; 1329 name.textContent = buttonLabel; 1330 button.append(kind, name); 1331 button.dataset.compactLabel = compactInnovationLabel(item); 1332 button.title = buttonLabel; 1333 button.setAttribute("aria-label", buttonLabel); 1334 button.addEventListener("click", () => { 1335 if (innovationAction(item).type === "za_chat") { 1336 openZaChatDirect(item); 1337 return; 1338 } 1339 openInnovation(item); 1340 }); 1341 innovationDock.append(button); 1342 }); 1343 } 1344 1345 function innovationAction(item) { 1346 return item && item.action && typeof item.action === "object" ? item.action : {}; 1347 } 1348 1349 function appendPanelText(text, className) { 1350 if (!text) return null; 1351 const paragraph = document.createElement("p"); 1352 paragraph.textContent = text; 1353 if (className) paragraph.className = className; 1354 innovationBody.append(paragraph); 1355 return paragraph; 1356 } 1357 1358 function setInnovationButtons(options) { 1359 const launchLabel = options && options.launchLabel ? options.launchLabel : "Open"; 1360 innovationLaunch.textContent = launchLabel; 1361 innovationLaunch.hidden = !!(options && options.launchHidden); 1362 innovationSpeak.hidden = !!(options && options.speakHidden); 1363 innovationStop.hidden = !!(options && options.stopHidden); 1364 } 1365 1366 function showInnovationPanel() { 1367 innovationPanel.hidden = false; 1368 readerBackdrop.hidden = false; 1369 } 1370 1371 function durationLabel(seconds) { 1372 const total = Number(seconds); 1373 if (!Number.isFinite(total) || total <= 0) return ""; 1374 const minutes = Math.floor(total / 60); 1375 const remaining = Math.floor(total % 60); 1376 return `${minutes}:${String(remaining).padStart(2, "0")}`; 1377 } 1378 1379 function renderAudioAddon(item) { 1380 const action = innovationAction(item); 1381 const media = Array.isArray(item.media) ? item.media : (Array.isArray(action.items) ? action.items : []); 1382 innovationBody.replaceChildren(); 1383 appendPanelText(item.body || "Listen to the audio configured for this page.", "addon-intro"); 1384 1385 if (!media.length) { 1386 appendPanelText("No audio items are configured for this page yet.", "addon-status"); 1387 setInnovationButtons({ launchHidden: true, speakHidden: true, stopHidden: true }); 1388 return; 1389 } 1390 1391 const list = document.createElement("div"); 1392 list.className = "audio-addon-list"; 1393 media.forEach((audioItem, index) => { 1394 const track = document.createElement("article"); 1395 track.className = "audio-addon-track"; 1396 1397 if (audioItem.cover) { 1398 const cover = document.createElement("img"); 1399 cover.className = "audio-addon-cover"; 1400 cover.src = audioItem.cover; 1401 cover.alt = ""; 1402 track.append(cover); 1403 } 1404 1405 const heading = document.createElement("h3"); 1406 heading.textContent = audioItem.title || `Audio ${index + 1}`; 1407 track.append(heading); 1408 1409 const metaParts = [ 1410 audioItem.interviewee, 1411 audioItem.languageLabel, 1412 durationLabel(audioItem.duration) 1413 ].filter(Boolean); 1414 if (metaParts.length) { 1415 const meta = document.createElement("p"); 1416 meta.className = "audio-addon-meta"; 1417 meta.textContent = metaParts.join(" · "); 1418 track.append(meta); 1419 } 1420 1421 const audio = document.createElement("audio"); 1422 audio.controls = true; 1423 audio.preload = "metadata"; 1424 audio.src = audioItem.url; 1425 audio.addEventListener("play", () => { 1426 innovationBody.querySelectorAll("audio").forEach(other => { 1427 if (other !== audio && !other.paused) other.pause(); 1428 }); 1429 }); 1430 track.append(audio); 1431 list.append(track); 1432 }); 1433 1434 innovationBody.append(list); 1435 setInnovationButtons({ launchHidden: true, speakHidden: true, stopHidden: true }); 1436 } 1437 1438 function renderExternalAddonIntro(item, fallbackBody) { 1439 innovationBody.replaceChildren(); 1440 appendPanelText(item.body || fallbackBody || "", "addon-intro"); 1441 const status = document.createElement("p"); 1442 status.className = "addon-status";
1443 status.dataset.addonStatus = "1"; 1444 status.textContent = "Ready."; 1445 innovationBody.append(status); 1446 return status; 1447 } 1448 1449 function setAddonStatus(text, tone) { 1450 const status = innovationBody.querySelector("[data-addon-status]"); 1451 if (!status) return; 1452 status.textContent = text; 1453 status.dataset.tone = tone || ""; 1454 } 1455 1456 function chatbotDetailForItem(item) { 1457 const action = innovationAction(item); 1458 const chatbot = item.chatbot || {}; 1459 return { 1460 subdomain: action.subdomain || chatbot.subdomain || "", 1461 name: action.name || chatbot.name || item.client || item.title || "Client Chat", 1462 page: currentPageNumber(), 1463 article: currentArticle || articlesForPage(currentPageNumber())[0] || null 1464 }; 1465 } 1466 1467 function callZaChat(command, ...args) { 1468 if (window.ZAChat && typeof window.ZAChat[command] === "function") { 1469 window.ZAChat[command](...args); 1470 return true; 1471 } 1472 if (typeof window.zachat === "function") { 1473 window.zachat(command, ...args); 1474 return true; 1475 } 1476 return false; 1477 } 1478 1479 async function openZaChatAddon(item) { 1480 const detail = chatbotDetailForItem(item); 1481 if (!detail.subdomain) { 1482 setAddonStatus("No chatbot subdomain is configured for this page.", "error"); 1483 return false; 1484 } 1485 1486 setAddonStatus(`Opening ${detail.name}...`, "loading"); 1487 window.__ZA_CHAT_AUTO_OPEN__ = true; 1488 1489 try { 1490 await loadScriptOnce("https://app.za.chat/js/widget_loader.js", "za-chat-loader-source"); 1491 if (window.ZAChat && typeof window.ZAChat.switchSubdomain === "function") { 1492 window.ZAChat.switchSubdomain(detail.subdomain); 1493 } else if (window.ZAChat && typeof window.ZAChat.init === "function") { 1494 window.ZAChat.init({ subdomain: detail.subdomain }); 1495 } else { 1496 callZaChat("init", { subdomain: detail.subdomain }); 1497 } 1498 1499 document.dispatchEvent(new CustomEvent("reader:chatbot-selected", { detail })); 1500 document.dispatchEvent(new CustomEvent("harvest:chatbot-selected", { 1501 detail: { subdomain: detail.subdomain, name: detail.name, page: detail.page } 1502 })); 1503 window.dataLayer = window.dataLayer || []; 1504 window.dataLayer.push({ 1505 event: "chat_event", 1506 chatbotName: detail.subdomain, 1507 label: detail.name, 1508 reader_page: detail.page 1509 }); 1510 setAddonStatus(`${detail.name} is open.`, "success"); 1511 return true; 1512 } catch (error) { 1513 console.warn("[LeadershipReader] ZA.Chat failed to load", error); 1514 setAddonStatus("The chatbot could not be loaded. Please try again.", "error"); 1515 return false; 1516 } 1517 } 1518 1519 async function openZaChatDirect(item) { 1520 activeInnovation = item; 1521 closePanels(); 1522 innovationTitle.textContent = item.title || item.label || "Client Chat"; 1523 renderExternalAddonIntro(item, "Open the page-specific client chatbot."); 1524 setInnovationButtons({ 1525 launchLabel: item.launchLabel || "Open chat", 1526 launchHidden: false, 1527 speakHidden: true, 1528 stopHidden: true 1529 }); 1530 if (!await openZaChatAddon(item)) showInnovationPanel(); 1531 } 1532 1533 let conversationalAiWidgetEl = null; 1534 let conversationalAiOpenRequest = 0; 1535 1536 function waitForConversationalAi(promise) { 1537 let timer; 1538 return Promise.race([ 1539 promise, 1540 new Promise((_, reject) => { 1541 timer = window.setTimeout(() => reject(new Error("assistant_loading_timeout")), 30000); 1542 }) 1543 ]).finally(() => window.clearTimeout(timer)); 1544 } 1545 1546 function ensureConversationalAiMount() { 1547 let mount = document.getElementById("chat-control-panel"); 1548 if (!mount) { 1549 mount = document.createElement("div"); 1550 mount.id = "chat-control-panel"; 1551 mount.className = "runtime-convo-panel"; 1552 const autoOpen = document.createElement("div"); 1553 autoOpen.id = "auto-open-toggle-container"; 1554 mount.append(autoOpen); 1555 innovationBody.append(mount); 1556 } 1557 // Panel re-renders detach the widget; getElementById cannot see detached 1558 // nodes, so keep a direct reference to restore it on reopen. 1559 const existingWidget = document.getElementById("convo-ai") || conversationalAiWidgetEl; 1560 if (existingWidget) { 1561 conversationalAiWidgetEl = existingWidget; 1562 if (existingWidget.parentElement !== mount) { 1563 mount.append(existingWidget); 1564 } 1565 existingWidget.hidden = false;
1566 } 1567 return mount; 1568 } 1569 1570 function activeChatbotForPage() { 1571 return innovationsForPage(currentPageNumber()).find(item => { 1572 const action = innovationAction(item); 1573 return action.type === "za_chat" && action.subdomain; 1574 }); 1575 } 1576 1577 async function openConversationalAiAddon(item) { 1578 const request = ++conversationalAiOpenRequest; 1579 const action = innovationAction(item); 1580 const experience = action.experience || configValue("conversationalAiExperience", [ 1581 "addons_experience", 1582 "conversational_ai_experience", 1583 "experience" 1584 ]); 1585 1586 if (!experience) { 1587 window.dispatchEvent(new CustomEvent(item.eventName || "leadership:open-conversational-ai", { 1588 detail: { innovation: item, page: currentPageNumber() } 1589 })); 1590 return; 1591 } 1592 1593 ensureConversationalAiMount(); 1594 setAddonStatus("Loading conversational AI...", "loading"); 1595 1596 try { 1597 window.conversationalAiExperience = experience; 1598 const scriptId = `conversational-ai-embed-${experience}`; 1599 if (window.conversationalAiLoader?.state === "failed") { 1600 document.getElementById(scriptId)?.remove(); 1601 loadedScriptPromises.delete(scriptId); 1602 } 1603 const embedUrl = new URL(action.embedSrc || "https://ai.capemedia.co.za/conversational_ai/js/embed.js", window.location.href); 1604 embedUrl.searchParams.set("v", "20260906-2"); 1605 await waitForConversationalAi(loadScriptOnce(embedUrl.toString(), scriptId, script => { 1606 script.setAttribute("data-convo-experience", experience); 1607 script.setAttribute("data-convo-placement", "inline"); 1608 script.setAttribute("data-convo-title", item.title || "Conversational AI"); 1609 script.setAttribute("data-convo-subtitle", "Voice chat"); 1610 script.setAttribute("data-convo-minimizable", "false"); 1611 script.setAttribute("data-convo-draggable", "false"); 1612 script.setAttribute("data-convo-auto-resume", "false"); 1613 })); 1614 const loader = window.conversationalAiLoader; 1615 if (!loader || loader.experience !== experience) throw new Error("assistant_loader_unavailable"); 1616 await waitForConversationalAi(loader.promise); 1617 conversationalAiWidgetEl = document.getElementById("convo-ai") || conversationalAiWidgetEl; 1618 if (request !== conversationalAiOpenRequest || innovationPanel.hidden) { 1619 if (conversationalAiWidgetEl && (innovationPanel.hidden || innovationAction(activeInnovation).type !== "conversational_ai")) { 1620 conversationalAiWidgetEl.hidden = true; 1621 } 1622 return; 1623 } 1624 ensureConversationalAiMount(); 1625 const chatbot = activeChatbotForPage(); 1626 const chatbotDetail = chatbot ? chatbotDetailForItem(chatbot) : {}; 1627 setAddonStatus("Connecting voice. Allow microphone access when prompted.", "loading"); 1628 document.dispatchEvent(new CustomEvent("convo:voice", { 1629 detail: { experience, page: currentPageNumber(), subdomain: chatbotDetail.subdomain || "" } 1630 })); 1631 } catch (error) { 1632 if (request !== conversationalAiOpenRequest || innovationPanel.hidden) return; 1633 console.warn("[LeadershipReader] Conversational AI failed to load", error); 1634 setAddonStatus("Conversational AI could not be loaded. Press Start AI to retry.", "error"); 1635 setInnovationButtons({ 1636 launchLabel: item.launchLabel || "Start AI", 1637 launchHidden: false, 1638 speakHidden: true, 1639 stopHidden: true 1640 }); 1641 } 1642 } 1643 1644 function openInnovation(item) { 1645 activeInnovation = item; 1646 closePanels({ keepBackdrop: true }); 1647 innovationTitle.textContent = item.title || item.label || "Innovation"; 1648 const action = innovationAction(item); 1649 if (action.type === "audio" || Array.isArray(item.media)) { 1650 renderAudioAddon(item); 1651 showInnovationPanel(); 1652 return; 1653 } 1654 if (action.type === "za_chat") { 1655 renderExternalAddonIntro(item, "Open the page-specific client chatbot."); 1656 setInnovationButtons({ 1657 launchLabel: item.launchLabel || "Open chat", 1658 launchHidden: false, 1659 speakHidden: true, 1660 stopHidden: true 1661 }); 1662 showInnovationPanel(); 1663 return; 1664 } 1665 if (action.type === "conversational_ai") { 1666 renderExternalAddonIntro(item, "Starting the conversational AI companion for this issue."); 1667 ensureConversationalAiMount(); 1668 setInnovationButtons({ 1669 launchLabel: item.launchLabel || "Start AI", 1670 launchHidden: true, 1671 speakHidden: true, 1672 stopHidden: true 1673 }); 1674 showInnovationPanel(); 1675 openConversationalAiAddon(item); 1676 return; 1677 } 1678 innovationBody.replaceChildren(); 1679 appendPanelText(item.body || ""); 1680 const isReadAloud = item.type === "readAloud"; 1681 setInnovationButtons({ 1682 launchLabel: item.launchLabel || "Open", 1683 launchHidden: item.type === "tldr" || isReadAloud, 1684 speakHidden: !isReadAloud, 1685 stopHidden: !isReadAloud 1686 }); 1687 showInnovationPanel(); 1688 } 1689 1690 function launchInnovation() { 1691 if (!activeInnovation) return; 1692 const action = innovationAction(activeInnovation); 1693 if (action.type === "za_chat") { 1694 openZaChatAddon(activeInnovation); 1695 return; 1696 } 1697 if (action.type === "conversational_ai") { 1698 openConversationalAiAddon(activeInnovation); 1699 return; 1700 } 1701 if (activeInnovation.url) { 1702 window.open(activeInnovation.url, "_blank", "noopener"); 1703 return; 1704 } 1705 window.dispatchEvent(new CustomEvent(activeInnovation.eventName || "leadership:innovation", { 1706 detail: { 1707 innovation: activeInnovation, 1708 page: currentPageNumber(), 1709 article: currentArticle || articlesForPage(currentPageNumber())[0] || null 1710 } 1711 })); 1712 } 1713 1714 function speakActiveInnovation() { 1715 if (!activeInnovation || !("speechSynthesis" in window)) return; 1716 window.speechSynthesis.cancel(); 1717 const utterance = new SpeechSynthesisUtterance(activeInnovation.body || activeInnovation.title || ""); 1718 utterance.lang = "en-ZA"; 1719 window.speechSynthesis.speak(utterance); 1720 } 1721 1722 function stopSpeaking() { 1723 if ("speechSynthesis" in window) window.speechSynthesis.cancel(); 1724 } 1725
1726 function openPanel(panel) { 1727 closePanels({ keepBackdrop: true }); 1728 panel.hidden = false; 1729 readerBackdrop.hidden = false; 1730 } 1731 1732 function closePanels(options) { 1733 conversationalAiOpenRequest += 1; 1734 const keepBackdrop = options && options.keepBackdrop; 1735 [searchDrawer, innovationPanel].forEach(panel => { 1736 if (panel) panel.hidden = true; 1737 }); 1738 if (!keepBackdrop && readerBackdrop) readerBackdrop.hidden = true; 1739 // A live voice session must never outlive its visible surface. 1740 document.dispatchEvent(new CustomEvent("convo:close")); 1741 } 1742 1743 window.addEventListener("conversational-ai-status", event => { 1744 const detail = event.detail || {}; 1745 if (innovationPanel.hidden || innovationAction(activeInnovation).type !== "conversational_ai") return; 1746 if (detail.experience !== window.conversationalAiExperience) return; 1747 if (detail.microphoneLive) setAddonStatus("Voice is connected. You can speak now.", "success"); 1748 else if (detail.status) setAddonStatus(detail.status, detail.connecting ? "loading" : "info"); 1749 }); 1750 1751 async function toggleFullscreen() { 1752 try { 1753 if (!document.fullscreenElement) { 1754 await document.documentElement.requestFullscreen(); 1755 } else { 1756 await document.exitFullscreen(); 1757 } 1758 } catch (_error) { 1759 // Fullscreen can be blocked by browser policy; leave the reader usable. 1760 } 1761 updateFullscreenLabel(); 1762 } 1763 1764 function updateFullscreenLabel() { 1765 // The button is icon-only; state lives in the tooltip/aria, not textContent 1766 // (writing textContent would wipe the SVG). 1767 fullscreenToggle.dataset.compactLabel = document.fullscreenElement ? "Exit" : "Full"; 1768 fullscreenToggle.title = document.fullscreenElement ? "Exit fullscreen" : "Fullscreen"; 1769 fullscreenToggle.setAttribute("aria-label", fullscreenToggle.title); 1770 } 1771
1772 function bindControls() { 1773 articleToggle.dataset.compactLabel = "Contents"; 1774 articleToggle.title = "Contents"; 1775 articleToggle.setAttribute("aria-label", "Go to contents page"); 1776 searchToggle.dataset.compactLabel = "Search"; 1777 searchToggle.title = "Search"; 1778 updateFullscreenLabel(); 1779 if (pageScrubber) { 1780 pageScrubber.min = "1"; 1781 pageScrubber.max = String(PAGE_COUNT); 1782 pageScrubber.value = "1"; 1783 pageScrubber.addEventListener("input", () => { 1784 pageLabel.textContent = pageLabelText(pageScrubber.value); 1785 }); 1786 pageScrubber.addEventListener("change", () => { 1787 currentArticle = null; 1788 goToPage(Number(pageScrubber.value), { animate: true }); 1789 }); 1790 } 1791 prevButton.addEventListener("click", () => pageFlip.flipPrev()); 1792 nextButton.addEventListener("click", () => pageFlip.flipNext()); 1793 articleToggle.addEventListener("click", goToContents); 1794 searchToggle.addEventListener("click", () => { 1795 renderSearchResults(searchInput.value); 1796 openPanel(searchDrawer); 1797 searchInput.focus(); 1798 }); 1799 fullscreenToggle.addEventListener("click", toggleFullscreen); 1800 searchInput.addEventListener("input", () => renderSearchResults(searchInput.value)); 1801 readerBackdrop.addEventListener("click", () => closePanels()); 1802 document.querySelectorAll("[data-close-panel]").forEach(button => { 1803 button.addEventListener("click", () => closePanels()); 1804 }); 1805 innovationLaunch.addEventListener("click", launchInnovation); 1806 innovationSpeak.addEventListener("click", speakActiveInnovation); 1807 innovationStop.addEventListener("click", stopSpeaking); 1808 document.addEventListener("fullscreenchange", () => { 1809 updateFullscreenLabel(); 1810 scheduleResizeReader(); 1811 }); 1812 document.addEventListener("keydown", event => { 1813 if (event.key === "Escape") closePanels(); 1814 }); 1815 window.addEventListener("resize", scheduleResizeReader); 1816 } 1817 1818 async function boot() { 1819 if (!window.St || !window.St.PageFlip) { 1820 statusNode.textContent = "StPageFlip unavailable"; 1821 return; 1822 } 1823 const indexReady = loadArticleIndex(); 1824 loadRuntimeInnovations(); 1825 await indexReady; 1826 createPages(); 1827 fitBookToStage(); 1828 pageFlip = new window.St.PageFlip(pageFlipRoot, { 1829 width: PAGE_WIDTH, 1830 height: PAGE_HEIGHT, 1831 size: "stretch", 1832 minWidth: 315, 1833 maxWidth: PAGE_WIDTH * 4, 1834 minHeight: 420, 1835 maxHeight: PAGE_HEIGHT * 4, 1836 showCover: true, 1837 usePortrait: true, 1838 mobileScrollSupport: false, 1839 clickEventForward: true, 1840 disableFlipByClick: true, 1841 drawShadow: true, 1842 flippingTime: FLIPPING_TIME 1843 }); 1844 const pages = pageFlipRoot.querySelectorAll(".page"); 1845 const load = pageFlip.loadFromHTML || pageFlip.loadFromHtml; 1846 load.call(pageFlip, pages); 1847 pageFlip.on("flip", event => { 1848 updateStatus(Number(event.data || 0)); 1849 updateFrameScales(); 1850 window.setTimeout(updateFrameScales, 700); 1851 }); 1852 pageFlip.on("changeState", syncEdgeTurnModeForState); 1853 createEdgeTurnZones(); 1854 bindControls(); 1855 renderSearchResults(""); 1856 goToPage(initialPageFromLocation()); 1857 resizeReader(); 1858 window.setTimeout(updateFrameScales, 300); 1859 document.body.classList.add("reader-ready"); 1860 const readerApi = { 1861 goToPage, 1862 goToArticle: goToArticleSlug, 1863 getPageCount: () => PAGE_COUNT, 1864 getCurrentPage: currentPageNumber, 1865 getCurrentArticle: () => currentArticle || articlesForPage(currentPageNumber())[0] || null, 1866 getFlipState: () => pageFlip && typeof pageFlip.getState === "function" ? pageFlip.getState() : null, 1867 getBookBounds: () => pageFlip && typeof pageFlip.getBoundsRect === "function" ? pageFlip.getBoundsRect() : null, 1868 holdPageCurl: holdRenderPageCurl, 1869 releasePageCurl: releaseRenderPageCurl, 1870 fitBookToStage, 1871 resizeReader, 1872 getAddonsPayload: () => runtimeAddonsPayload, 1873 getInnovationsForPage: pageNumber => innovationsForPage(pageNumber || currentPageNumber()), 1874 refreshAddons: loadRuntimeInnovations, 1875 onPageChange: callback => { 1876 if (typeof callback !== "function") return () => {}; 1877 pageChangeCallbacks.push(callback); 1878 return () => { 1879 const index = pageChangeCallbacks.indexOf(callback); 1880 if (index >
1880= 0) pageChangeCallbacks.splice(index, 1); 1881 }; 1882 }, 1883 openInnovation: id => { 1884 const item = innovations.find(innovation => innovation.id === id); 1885 if (item) openInnovation(item); 1886 } 1887 }; 1888 window.LeadershipInDesignReader = readerApi; 1889 window.CapeMagazineReader = readerApi; 1890 window.nav = Object.assign(window.nav || {}, { 1891 page: currentPageNumber(), 1892 pageOffset: PAGE_NUMBER_OFFSET, 1893 to: pageNumber => goToPage(Number(pageNumber), { animate: true }) 1894 }); 1895 } 1896 1897 window.addEventListener("DOMContentLoaded", boot); 1898}());
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.