PageSourceSearch

https://www.harvestsa.co.za/magazine/84/_engine/reader.js?v=p31-reading-s2-20260920

js harvestsa.co.za collected 2026-10-04 13:25:16 UTC 74,409 bytes, 1,898 lines download raw bytes

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.