From cb3f46c76fddc73bce441775032f10c167088c3f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 09:43:06 +0000 Subject: [PATCH 1/7] Phase 1: rendering reliability and accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep the fixed 1280×720 scaled canvas on narrow screens (scrollActivationWidth: null) instead of reveal 6's scroll view, which clipped two-column slides on portrait phones. - Reserve a real strip for a compact footer on shallow screens (max-height 560px) so the footer can no longer cover slide content; desktop rendering is unchanged. - Make the image lightbox fully keyboard-accessible: zoomable images are focusable controls (Enter/Space opens), the dialog focuses its close button, traps Tab, keeps arrow-key navigation and Escape-to-close, restores focus on close, and mirrors its visual state with inert/aria-hidden. French strings added. - Fix TOC modal semantics and behaviour: entries wrapped in
  • , focus moves into the dialog and is restored on close, arrow keys move between entries instead of driving the deck, aria-current marks the active entry, unused data-toc-part removed. - Promote stable per-talk components (.byline .role, .byline.authors, .author-logo, .demo-wrap/.demo-shot) into shared/theme.css and slim the per-deck style blocks; remove the dead .shot-todo component. - Fit engine: measure horizontal overflow too, keep centred layouts centred when fitted, expose the applied scale as data-fit, warn below 0.95 and stamp data-fit-fail below 0.90 (data-fit-allow opts out), re-fit after late-loading media, ?no-fit/?audit disables fitting; ?check now shows the fit scale. - Generic file embeds (data-embed-src, data-source-url, data-error-message) with accessible loading/failure states. - Centralise live-iframe lazy-loading + offline fallback in deck.js. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0122f4bMszLPwGVSkuLHt3er --- shared/deck.js | 328 +++++++++++++++--- shared/theme.css | 67 +++- .../index.html | 5 +- .../index.html | 22 +- .../index.html | 32 +- .../index.html | 22 +- .../index.html | 24 +- talks/_template/index.html | 15 +- 8 files changed, 366 insertions(+), 149 deletions(-) diff --git a/shared/deck.js b/shared/deck.js index e865eab..cadc62d 100644 --- a/shared/deck.js +++ b/shared/deck.js @@ -25,11 +25,22 @@ Add a language by extending I18N; decks opt in via . -------- */ var LANG = (CFG.lang || document.documentElement.lang || "en").slice(0, 2).toLowerCase(); var I18N = { - en: { contents: "Contents", overview: "overview", close: "close", prev: "Previous slide", next: "Next slide", tocOpen: "Open table of contents", tocAria: "Table of contents", closeAria: "Close" }, - fr: { contents: "Sommaire", overview: "aperçu", close: "fermer", prev: "Diapo précédente", next: "Diapo suivante", tocOpen: "Ouvrir le sommaire", tocAria: "Sommaire", closeAria: "Fermer" } + en: { contents: "Contents", overview: "overview", close: "close", prev: "Previous slide", next: "Next slide", tocOpen: "Open table of contents", tocAria: "Table of contents", closeAria: "Close", + imageViewer: "Image viewer", imageClose: "Close image", imageView: "View image full screen", imagePrev: "Previous image", imageNext: "Next image", + embedLoading: "Loading file…", embedError: "Could not load the file.", embedSource: "View the source", + frameUnavailable: "Live view unavailable — it needs a network connection.", frameOpen: "Open the site" }, + fr: { contents: "Sommaire", overview: "aperçu", close: "fermer", prev: "Diapo précédente", next: "Diapo suivante", tocOpen: "Ouvrir le sommaire", tocAria: "Sommaire", closeAria: "Fermer", + imageViewer: "Visionneuse d’images", imageClose: "Fermer l’image", imageView: "Afficher l’image en plein écran", imagePrev: "Image précédente", imageNext: "Image suivante", + embedLoading: "Chargement du fichier…", embedError: "Impossible de charger le fichier.", embedSource: "Voir la source", + frameUnavailable: "Aperçu en direct indisponible — une connexion réseau est requise.", frameOpen: "Ouvrir le site" } }; var STR = I18N[LANG] || I18N.en; + /* Dev-mode flags: ?check outlines overflow + shows fit scales; ?no-fit (or + ?audit) disables auto-fitting so authored overflow is visible raw. */ + var CHECK_MODE = /[?&](check|audit)\b/.test(location.search); + var NO_FIT = /[?&](no-fit|audit)\b/.test(location.search); + // Folder this script lives in (e.g. .../shared/) so engine assets resolve no // matter how deep the talk page sits. Captured while currentScript is valid. var SCRIPT_BASE = (function () { @@ -137,19 +148,19 @@ var entries = []; hSlides.forEach(function (sec, h) { var label = sec.getAttribute("data-toc"); - if (label) entries.push({ h: h, label: label, part: sec.getAttribute("data-toc-part") || "" }); + if (label) entries.push({ h: h, label: label }); }); if (!entries.length) return; // no TOC requested var rows = entries.map(function (e, i) { var n = String(i + 1).padStart(2, "0"); var folio = String(e.h + 1).padStart(2, "0"); - return '"; + "
  • "; }).join(""); overlay = elem( @@ -176,25 +187,60 @@ }); overlay.querySelector(".toc-close").addEventListener("click", closeTOC); overlay.addEventListener("click", function (e) { if (e.target === overlay) closeTOC(); }); - overlay.addEventListener("keydown", trapTOCFocus); + setDialogHidden(overlay, true); + } + /* Keep an overlay's accessibility state in sync with its visual state: + `inert` (with aria-hidden fallback) while closed, interactive while open. */ + function setDialogHidden(dialog, hiddenState) { + if (!dialog) return; + if ("inert" in dialog) dialog.inert = hiddenState; + if (hiddenState) dialog.setAttribute("aria-hidden", "true"); + else dialog.removeAttribute("aria-hidden"); + } + /* Focus an element once its dialog is actually visible: the overlays fade in + via a visibility transition, and focus() is a no-op while the computed + visibility is still hidden (the first frame after the class flips). */ + function focusWhenVisible(el) { + if (!el) return; + requestAnimationFrame(function () { requestAnimationFrame(function () { el.focus(); }); }); } var tocLastFocus = null; function openTOC() { if (!overlay) return; tocLastFocus = document.activeElement; overlay.classList.add("open"); + setDialogHidden(overlay, false); markCurrentTOC(); // Move focus into the dialog (current entry if any, else the first). - var target = overlay.querySelector(".toc-item.current") || overlay.querySelector(".toc-item"); - if (target) target.focus(); + focusWhenVisible(overlay.querySelector(".toc-item.current") || overlay.querySelector(".toc-item")); } function closeTOC() { if (!overlay) return; overlay.classList.remove("open"); + setDialogHidden(overlay, true); if (tocLastFocus && tocLastFocus.focus) tocLastFocus.focus(); // restore focus to the trigger tocLastFocus = null; } function toggleTOC(){ if (overlay) (overlay.classList.contains("open") ? closeTOC() : openTOC()); } + /* While the TOC is open, keys must act on the DIALOG, never on the deck + behind it: Tab is trapped inside, arrows move between entries, Escape + closes, and everything else is stopped before reveal's own key handler. + Runs in the capture phase on document so it wins over Reveal. */ + function tocKeydown(e) { + if (!overlay || !overlay.classList.contains("open")) return; + if (e.key === "Tab") { trapTOCFocus(e); e.stopPropagation(); return; } + if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeTOC(); return; } + var f = Array.prototype.slice.call( + overlay.querySelectorAll(".toc-item") + ).filter(function (el) { return !el.disabled && el.offsetParent !== null; }); + var idx = f.indexOf(document.activeElement); + if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); if (f.length) f[(idx + 1 + f.length) % f.length].focus(); } + else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); if (f.length) f[(idx - 1 + f.length) % f.length].focus(); } + else if (e.key === "Home") { e.preventDefault(); if (f.length) f[0].focus(); } + else if (e.key === "End") { e.preventDefault(); if (f.length) f[f.length - 1].focus(); } + // Whatever the key, never let it drive the presentation behind the dialog. + e.stopPropagation(); + } /* Keep Tab inside the open dialog (simple focus trap). */ function trapTOCFocus(e) { if (e.key !== "Tab" || !overlay || !overlay.classList.contains("open")) return; @@ -204,7 +250,7 @@ if (!f.length) return; var first = f[0], last = f[f.length - 1], a = document.activeElement; if (e.shiftKey && (a === first || !overlay.contains(a))) { e.preventDefault(); last.focus(); } - else if (!e.shiftKey && a === last) { e.preventDefault(); first.focus(); } + else if (!e.shiftKey && (a === last || !overlay.contains(a))) { e.preventDefault(); first.focus(); } } function markCurrentTOC() { if (!overlay) return; @@ -214,8 +260,9 @@ var bh = parseInt(btn.getAttribute("data-h"), 10); if (bh <= h) active = btn; btn.classList.remove("current"); + btn.removeAttribute("aria-current"); }); - if (active) active.classList.add("current"); + if (active) { active.classList.add("current"); active.setAttribute("aria-current", "page"); } } /* ---- per-slide sync ----------------------------------------------------- */ @@ -251,13 +298,33 @@ area (gold-rule clearance on hero slides; footer reserve below). Slides that already fit are never touched; an overflowing one is wrapped in an absolutely placed .fit box and scaled to fit. Runs once per slide, after webfonts settle - so the measurement is real. ------------------------------------------------ */ + so the measurement is real — and again (forced) when delayed content such as + an image or a file embed resolves and changes the slide's height. + + Fitting is VISIBLE, not silent: every fitted slide carries data-fit with the + applied scale; a scale below FIT_WARN logs a warning; below FIT_FAIL the + slide is stamped data-fit-fail (a validation failure) unless the author + explicitly allows it with a data-fit-allow attribute on the
    . + ?no-fit / ?audit disables fitting entirely so raw overflow can be seen. --- */ + var FIT_WARN = 0.95, FIT_FAIL = 0.90; var fitReady = !(document.fonts && document.fonts.ready); var FIT_SEEN = (typeof WeakSet === "function") ? new WeakSet() : null; - function fitSlide(sec) { - if (!sec || !fitReady) return; - if (FIT_SEEN && FIT_SEEN.has(sec)) return; - if (sec.querySelector(":scope > .fit")) { if (FIT_SEEN) FIT_SEEN.add(sec); return; } + /* Layouts that centre content vertically: their .fit box spans the whole safe + area and keeps the content centred while it scales. */ + var CENTERED = ["cover", "section", "statement", "closing", "metric", "center", "balance"]; + function unwrapFit(sec) { + var fit = sec.querySelector(":scope > .fit"); + if (!fit) return; + while (fit.firstChild) sec.insertBefore(fit.firstChild, fit); + sec.removeChild(fit); + sec.removeAttribute("data-fit"); + sec.removeAttribute("data-fit-fail"); + } + function fitSlide(sec, force) { + if (!sec || !fitReady || NO_FIT) return; + if (!force && FIT_SEEN && FIT_SEEN.has(sec)) return; + if (force) unwrapFit(sec); + else if (sec.querySelector(":scope > .fit")) { if (FIT_SEEN) FIT_SEEN.add(sec); return; } var cs = getComputedStyle(sec); var padT = parseFloat(cs.paddingTop) || 0, padB = parseFloat(cs.paddingBottom) || 0; var padL = parseFloat(cs.paddingLeft) || 0, padR = parseFloat(cs.paddingRight) || 0; @@ -270,26 +337,55 @@ return pos !== "absolute" && pos !== "fixed"; }); if (!kids.length) { if (FIT_SEEN) FIT_SEEN.add(sec); return; } - var topMost = Infinity, botMost = -Infinity; + var topMost = Infinity, botMost = -Infinity, leftMost = Infinity, rightMost = -Infinity; kids.forEach(function (c) { topMost = Math.min(topMost, c.offsetTop); botMost = Math.max(botMost, c.offsetTop + c.offsetHeight); + leftMost = Math.min(leftMost, c.offsetLeft); + rightMost = Math.max(rightMost, c.offsetLeft + c.offsetWidth); }); - var H = botMost - topMost; + var H = botMost - topMost, W = rightMost - leftMost; var boxTop = padT + clearance; var safeH = sec.clientHeight - boxTop - padB; - var needFit = (H > safeH + 3) || (hasRule && topMost < boxTop - 3); + var safeW = sec.clientWidth - padL - padR; + var needFit = (H > safeH + 3) || (W > safeW + 3) || (hasRule && topMost < boxTop - 3); if (needFit && safeH > 40 && H > 0) { - var k = Math.max(0.55, Math.min(1, safeH / H)); + var k = Math.max(0.55, Math.min(1, safeH / H, W > 0 ? safeW / W : 1)); + var centered = CENTERED.some(function (c) { return sec.classList.contains(c); }); var fit = document.createElement("div"); fit.className = "fit"; while (kids.length) fit.appendChild(kids.shift()); sec.insertBefore(fit, sec.firstChild); fit.style.cssText = "position:absolute;top:" + boxTop + "px;left:" + padL + "px;right:" + padR + - "px;margin:0;display:flex;flex-direction:column;transform-origin:top left;transform:scale(" + k.toFixed(4) + ");"; + "px;margin:0;display:flex;flex-direction:column;" + + (centered + ? "bottom:" + padB + "px;justify-content:center;transform-origin:center center;" + : "transform-origin:top left;") + + "transform:scale(" + k.toFixed(4) + ");"; sec.setAttribute("data-fit", k.toFixed(3)); + if (k < FIT_FAIL && !sec.hasAttribute("data-fit-allow")) { + sec.setAttribute("data-fit-fail", k.toFixed(3)); + console.error("deck: slide " + slideRef(sec) + " auto-fitted to ×" + k.toFixed(3) + + " (below the " + FIT_FAIL + " readability threshold). Trim the slide or add data-fit-allow."); + } else if (k < FIT_WARN) { + console.warn("deck: slide " + slideRef(sec) + " auto-fitted to ×" + k.toFixed(3) + " — consider trimming it."); + } } if (FIT_SEEN) FIT_SEEN.add(sec); + if (checkModeUpdate) checkModeUpdate(); + } + function slideRef(sec) { + var hSlides = Reveal.getHorizontalSlides ? Reveal.getHorizontalSlides() : []; + var i = hSlides.indexOf(sec); + var title = sec.querySelector("h1, h2, h3"); + return "#" + (i >= 0 ? i + 1 : "?") + (title ? " (“" + title.textContent.trim().slice(0, 40) + "”)" : ""); + } + /* Re-fit a slide when late-loading content (images, embeds, iframes) changes + its measured height after the first pass. */ + function refitAfterLoad(el) { + var sec = el && el.closest ? el.closest(".slides > section") : null; + if (!sec || !fitReady) return; + if ((FIT_SEEN && FIT_SEEN.has(sec)) || sec.querySelector(":scope > .fit")) fitSlide(sec, true); } /* ---- duotone filters (Move 2): inject the green/navy duotone SVG filters @@ -373,12 +469,20 @@ }); } - /* Load any [data-skill-src] panel from its vendored file and syntax-highlight it. */ - function loadSkillEmbeds() { - document.querySelectorAll("[data-skill-src]").forEach(function (panel) { + /* Load any [data-embed-src] / [data-skill-src] panel from its vendored file + and syntax-highlight it. Generic: optional data-error-message overrides the + failure text, optional data-source-url adds a link to the original. + Loading / success / failure states are exposed accessibly (aria-busy, + role=status/alert), and the slide is re-fitted once the embed resolves. */ + function loadFileEmbeds() { + document.querySelectorAll("[data-embed-src], [data-skill-src]").forEach(function (panel) { var code = panel.querySelector("code"); if (!code) return; - fetch(panel.getAttribute("data-skill-src")) + var src = panel.getAttribute("data-embed-src") || panel.getAttribute("data-skill-src"); + panel.setAttribute("aria-busy", "true"); + panel.setAttribute("role", "status"); + code.textContent = STR.embedLoading; + fetch(src) .then(function (r) { if (!r.ok) throw r.status; return r.text(); }) .then(function (text) { code.textContent = text; @@ -389,14 +493,26 @@ code.classList.remove("hljs"); try { hl.highlightElement(code); } catch (e) {} } + panel.removeAttribute("aria-busy"); + panel.removeAttribute("role"); + refitAfterLoad(panel); }) .catch(function () { - code.textContent = "Could not load the file — it's open source at github.com/fmadore/iwac-mcp-server"; + var msg = panel.getAttribute("data-error-message") || STR.embedError; + var url = panel.getAttribute("data-source-url"); + code.textContent = msg + (url ? " — " + STR.embedSource + ": " + url : ""); + panel.removeAttribute("aria-busy"); + panel.setAttribute("role", "alert"); + refitAfterLoad(panel); }); }); } - /* ---- image lightbox: click a figure/screenshot to view it full-screen --- */ + /* ---- image lightbox: view a figure/screenshot full-screen ---------------- + Fully keyboard-operable: every zoomable image is a focusable control that + opens with Enter/Space; the dialog focuses its close button, traps Tab, + navigates with the arrow keys, closes with Escape, and returns focus to + the originating image. hidden/inert state mirrors the visual state. ------ */ var lightbox, lbImg; function buildLightbox() { var imgs = document.querySelectorAll( @@ -404,16 +520,18 @@ ); if (!imgs.length) return; lightbox = elem( - '