ids theme: speaker notes overlay button for touch devices

The reveal.js speaker view ('s') opens a separate window, which is
awkward on mobile browsers. Add a button next to the fullscreen toggle
(shown on coarse-pointer devices only, like the fullscreen button) that
covers the slide with a full-screen overlay showing the current slide's
speaker notes, its title and planned time (from data-timing). The
overlay follows slide changes while open, closes with the X button or
Escape, is hidden in print output, and the button only appears when the
deck contains notes at all.

Change-Id: I50c6cd701115a973e8c6ba35448b13d5c722ab5a
diff --git a/NEWS.md b/NEWS.md
index 160a1bb..50c3c4a 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,7 @@
 # revealjs.ids (development version)
 
+- `ids` theme: on touch devices, a new button next to the fullscreen toggle overlays the current slide with its speaker notes (plus the slide title and planned time, if annotated with a timing comment) -- for practicing talks on mobile browsers, where reveal.js' separate speaker view window is awkward. The overlay follows slide changes and only appears when the deck contains notes.
+
 - Documents with a `bibliography:` in their YAML header are now formatted with the bundled IDS citation style by default (`ids.csl`, based on the style developed for ICLC-10 2023 in Mannheim). A `csl:` entry in the document's YAML header still takes precedence.
 
 - Speaker timing comments in the source `.Rmd` (e.g. `<!-- JD 00:30 -->` for "John Doe needs 30 seconds for this slide", or just `<!-- 00:30 -->` for single-speaker talks, ported from the old Google Docs workflow) are now converted into `data-timing` attributes on the rendered slides' `<section>` elements, activating the pacing timer in the reveal.js speaker view (the calculated grand total is passed as the `totalTime` config value; a `totalTime` or `defaultTiming` set by the document itself, e.g. to start from a known time budget, takes precedence). A per-speaker time summary is printed during rendering, and the new `slide_timing()` function computes slide, speaker, and grand totals directly from an `.Rmd` file without rendering it.
diff --git a/R/revealjs_presentation.R b/R/revealjs_presentation.R
index 950b8b8..f641284 100644
--- a/R/revealjs_presentation.R
+++ b/R/revealjs_presentation.R
@@ -33,6 +33,12 @@
 #' ```
 #' to create notes only viewable in presentation mode.
 #' 
+#' On mobile browsers (touch devices), a button next to the fullscreen
+#' toggle overlays the current slide with its speaker notes (plus the slide
+#' title and, if present, the planned time from a timing comment), which is
+#' useful for practicing a talk on a phone or tablet. It follows along as
+#' you change slides and only appears if the deck contains notes at all.
+#' 
 #' ## Slide timing
 #' 
 #' Speaker time can be planned per slide and per speaker by adding a comment
diff --git a/README.Rmd b/README.Rmd
index 57e9cfa..11a7f6b 100644
--- a/README.Rmd
+++ b/README.Rmd
@@ -109,6 +109,8 @@
 
 -   `'s'` enable presentation mode with speaker notes when the Notes plugin is activated
 
+On mobile browsers (touch devices), a speech-bubble button next to the fullscreen button overlays the current slide with its speaker notes -- useful for practicing a talk on a phone or tablet, where the separate speaker view window of the `'s'` shortcut is awkward. The overlay shows the slide title, its planned time (if annotated with a timing comment), and the notes, and follows along as you change slides. It appears only if the deck contains notes at all.
+
 -   `'m'` enable menu mode when the 'menu' plugin is activated
 
 Pressing `Esc` exits all of these modes.
diff --git a/README.md b/README.md
index b3c2dab..930c084 100644
--- a/README.md
+++ b/README.md
@@ -126,10 +126,18 @@
 
 - `'?'` enable help mode to show keyboard shortcut cheatsheet
 
-- `'s'` enable presentation mode with speaker notes when the Notes
-  plugin is activated
+-   `'s'` enable presentation mode with speaker notes when the Notes
+    plugin is activated
 
-- `'m'` enable menu mode when the ‘menu’ plugin is activated
+On mobile browsers (touch devices), a speech-bubble button next to the
+fullscreen button overlays the current slide with its speaker notes --
+useful for practicing a talk on a phone or tablet, where the separate
+speaker view window of the `'s'` shortcut is awkward. The overlay shows
+the slide title, its planned time (if annotated with a timing comment),
+and the notes, and follows along as you change slides. It appears only
+if the deck contains notes at all.
+
+-   `'m'` enable menu mode when the ‘menu’ plugin is activated
 
 Pressing `Esc` exits all of these modes.
 
diff --git a/inst/reveal.js-5.2.1/dist/theme/ids.css b/inst/reveal.js-5.2.1/dist/theme/ids.css
index ee3b816..8f95547 100644
--- a/inst/reveal.js-5.2.1/dist/theme/ids.css
+++ b/inst/reveal.js-5.2.1/dist/theme/ids.css
@@ -926,6 +926,92 @@
   display: none !important;
 }
 
+/*********************************************
+ * SPEAKER NOTES OVERLAY (touch devices)
+ *********************************************/
+#ids-notes {
+  display: none;
+  position: fixed;
+  left: 70px;
+  bottom: 6%;
+  z-index: 30;
+  width: 28px;
+  height: 28px;
+  border: 0;
+  padding: 0;
+  cursor: pointer;
+  opacity: 0.55;
+  background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23aaaaaa' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E") center/contain no-repeat;
+}
+#ids-notes:active, #ids-notes:focus-visible {
+  opacity: 0.9;
+}
+#ids-notes.is-open {
+  opacity: 0.9;
+  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23f6a800' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E");
+}
+
+@media (hover: none) and (pointer: coarse) {
+  #ids-notes {
+    display: block;
+  }
+}
+html.reveal-print #ids-notes {
+  display: none !important;
+}
+
+#ids-notes-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 100;
+  background: #fff;
+  color: #444444;
+  font-family: "Fira Sans", "Fira Sans Condensed", sans-serif;
+  overflow-y: auto;
+  -webkit-overflow-scrolling: touch;
+  padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
+           max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
+}
+#ids-notes-overlay[hidden] {
+  display: none;
+}
+#ids-notes-header {
+  display: flex;
+  align-items: baseline;
+  gap: 12px;
+  border-bottom: 1px solid #e0e0e0;
+  margin-bottom: 16px;
+  padding-bottom: 8px;
+}
+#ids-notes-timing {
+  flex-shrink: 0;
+  color: #f6a800;
+  font-weight: bold;
+  white-space: nowrap;
+}
+#ids-notes-title {
+  flex-grow: 1;
+  margin: 0;
+  font-size: 1.1em;
+}
+#ids-notes-close {
+  flex-shrink: 0;
+  border: 0;
+  background: none;
+  color: #888888;
+  font-size: 1.2em;
+  cursor: pointer;
+  padding: 8px;
+  margin: -8px;
+}
+#ids-notes-content {
+  font-size: 20px;
+  line-height: 1.5;
+}
+#ids-notes-content em {
+  color: #888888;
+}
+
 .reveal .controls .controls-arrow:after, .reveal .controls .controls-arrow:before {
   width: 13px !important; /* size */
   height: 4px; /* line width */
diff --git a/inst/rmarkdown/templates/revealjs_presentation/resources/default.html b/inst/rmarkdown/templates/revealjs_presentation/resources/default.html
index 7370f80..55fdfaf 100644
--- a/inst/rmarkdown/templates/revealjs_presentation/resources/default.html
+++ b/inst/rmarkdown/templates/revealjs_presentation/resources/default.html
@@ -866,6 +866,15 @@
     <footer id="ids-footer"><span>
       $for(author)$$if(author.name)$$author.name$$else$$author$$endif$${ sep }, $endfor$ · $title$ · $date$</span></footer>
 <button id="ids-fullscreen" aria-label="Toggle fullscreen" title="Fullscreen"></button>
+<button id="ids-notes" aria-label="Show speaker notes" title="Speaker notes"></button>
+<div id="ids-notes-overlay" hidden>
+  <header id="ids-notes-header">
+    <span id="ids-notes-timing" role="status"></span>
+    <h2 id="ids-notes-title"></h2>
+    <button id="ids-notes-close" aria-label="Close speaker notes">&#x2715;</button>
+  </header>
+  <div id="ids-notes-content"></div>
+</div>
 <script>
   // IDS theme: touch devices have no F11 and the browser chrome eats slide
   // pixels, so offer a small fixed fullscreen toggle (styled in the theme;
@@ -894,6 +903,67 @@
     });
   })();
 </script>
+<script>
+  // IDS theme: the reveal.js speaker view ('s') opens a separate window,
+  // which is awkward on mobile browsers. Offer a button (styled in the
+  // theme; only shown on coarse-pointer devices) that overlays the slide
+  // with the speaker notes of the current slide -- useful for practicing
+  // a talk on a phone or tablet.
+  (function () {
+    var btn = document.getElementById('ids-notes');
+    var overlay = document.getElementById('ids-notes-overlay');
+    if (!btn || !overlay || typeof Reveal === 'undefined') return;
+
+    // no need for the button if the deck has no notes at all
+    if (!document.querySelector('aside.notes, [data-notes]')) {
+      btn.style.display = 'none';
+      return;
+    }
+
+    function formatTiming(seconds) {
+      var m = Math.floor(seconds / 60);
+      var s = Math.round(seconds % 60);
+      return m + ':' + ('0' + s).slice(-2) + ' min';
+    }
+
+    function update() {
+      var slide = Reveal.getCurrentSlide();
+      if (!slide) return;
+      var heading = slide.querySelector('h1, h2, h3');
+      document.getElementById('ids-notes-title').textContent =
+        heading ? heading.textContent : '';
+      var timing = parseInt(slide.getAttribute('data-timing'));
+      document.getElementById('ids-notes-timing').textContent =
+        isNaN(timing) ? '' : formatTiming(timing);
+      var notes = slide.querySelectorAll('aside.notes, [data-notes]');
+      document.getElementById('ids-notes-content').innerHTML =
+        Array.prototype.map.call(notes, function (n) { return n.innerHTML; }).join('')
+        || '<p><em>No notes on this slide.</em></p>';
+      overlay.scrollTop = 0;
+    }
+
+    function setOpen(on) {
+      overlay.hidden = !on;
+      btn.classList.toggle('is-open', on);
+      if (on) update();
+    }
+
+    btn.addEventListener('click', function (e) {
+      e.stopPropagation();
+      setOpen(overlay.hidden);
+    });
+    document.getElementById('ids-notes-close').addEventListener('click', function (e) {
+      e.stopPropagation();
+      setOpen(false);
+    });
+    document.addEventListener('keydown', function (e) {
+      if (e.key === 'Escape' && !overlay.hidden) setOpen(false);
+    });
+    Reveal.addEventListener('slidechanged', function () {
+      if (!overlay.hidden) update();
+    });
+  })();
+</script>
 $endif$
   </body>
 </html>