From 36a92c0d43eafd62fa13ed3ee482e1be94179d60 Mon Sep 17 00:00:00 2001 From: Mitch Date: Tue, 9 Jun 2026 11:39:38 +1000 Subject: [PATCH] fix(runtime): harden navigation against overlays, scroll, and stray touches Navigation leaked through overlays, misread diagonal scroll, and stole horizontal scroll from wide code blocks, and touch swipes fired on vertical drags and flip-card taps. Add an overlay guard, axis-locked wheel and touch handling that respects scrollable targets, and a standalone replayActiveSlide() for restarting a slide's entry animations. Co-Authored-By: Leslie Barbara Knope (Claude Opus 4.8 (1M context)) --- SKILL.md | 1 + assets/slides-runtime.js | 54 +++++- references/html-template.md | 55 +++++- references/presentation-layer.md | 13 ++ .../fixtures/deck-overlay-and-codeblock.html | 173 ++++++++++++++++++ 5 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 testing/fixtures/deck-overlay-and-codeblock.html diff --git a/SKILL.md b/SKILL.md index 37d3073d..24fae99c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -457,6 +457,7 @@ Before saving, verify all 8 spec rules pass. Fix any that fail. Save the HTML fi 3. **Summarize** — Tell the user: - File location, style name, slide count - Navigation: Arrow keys, Space, scroll/swipe, click nav dots + - Public functions: `goTo(index)`, `next()`, `prev()`, and `replayActiveSlide()` to restart the current slide's entry animations - Speaker notes: Open DevTools (F12), detach to separate window — notes appear in console on each slide change - How to customize: `:root` CSS variables for colors, font link for typography, `.reveal` class for animations - If inline editing was enabled: Hover top-left corner or press E to enter edit mode, click any text to edit, Ctrl+S to save diff --git a/assets/slides-runtime.js b/assets/slides-runtime.js index 9af41234..b1da115b 100644 --- a/assets/slides-runtime.js +++ b/assets/slides-runtime.js @@ -39,7 +39,25 @@ function updateUI() { document.querySelectorAll('.slide-nav-dot').forEach((d,i) => d.classList.toggle('active', i===current)); } +// True when an overlay owns navigation. Authors mark custom overlays with +// data-blocks-nav; the skill's own lightbox is covered with no wiring. +function navBlocked() { + return !!(document.querySelector('.diagram-lightbox.active') || + document.querySelector('[data-blocks-nav]:not([hidden])')); +} + +// Restart the staggered entry animations on the current slide. Standalone so +// the next, prev, keyboard, and dot contract stays untouched. +function replayActiveSlide() { + const s = slides[current]; + if (!s) return; + s.classList.remove('active'); + void s.offsetWidth; // force reflow so the re-add restarts the animations + s.classList.add('active'); +} + function goTo(index) { + if (navBlocked()) return; if (index<0 || index>=total || index===current) return; const prev = current; current = index; @@ -66,18 +84,44 @@ document.addEventListener('keydown', (e) => { if (e.key==='End') { e.preventDefault(); goTo(total-1); } }); -let touchStart = 0; -document.addEventListener('touchstart', (e) => { touchStart=e.touches[0].clientX; }); +// True when the target sits inside an element that can scroll under the +// pointer, so wheel and swipe deltas belong to the content, not the deck. +function inScrollable(target) { + let el = target; + while (el && el !== document.body) { + if (el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight) return true; + el = el.parentElement; + } + return false; +} + +let touchX = 0, touchY = 0, touchT = 0; +document.addEventListener('touchstart', (e) => { + touchX = e.touches[0].clientX; + touchY = e.touches[0].clientY; + touchT = Date.now(); +}); document.addEventListener('touchend', (e) => { - const diff = touchStart - e.changedTouches[0].clientX; - if (Math.abs(diff)>50) { diff>0 ? next() : prev(); } + if (e.changedTouches.length > 1) return; // ignore multi-touch gestures + // Let interactive and flippable targets keep their own gestures. + if (e.target.closest('.flip-card, a, button, input, textarea, select')) return; + const dx = e.changedTouches[0].clientX - touchX; + const dy = e.changedTouches[0].clientY - touchY; + if (Math.abs(dx) <= Math.abs(dy)) return; // require horizontal dominance + if (Math.abs(dx) < 50) return; // require a deliberate distance + if (Date.now() - touchT > 600) return; // reject slow drags + dx < 0 ? next() : prev(); }); let wheelCD = false; document.addEventListener('wheel', (e) => { + if (inScrollable(e.target)) return; // let scrollable content keep the delta + if (Math.abs(e.deltaX) < 10 && Math.abs(e.deltaY) < 10) return; // ignore noise if (wheelCD) return; wheelCD = true; setTimeout(() => wheelCD=false, 600); - if (e.deltaY>0||e.deltaX>0) next(); else prev(); + // Navigate on the dominant axis only, so a diagonal scroll never reverses. + const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + delta > 0 ? next() : prev(); }, {passive:true}); updateUI(); diff --git a/references/html-template.md b/references/html-template.md index d48d56c1..c396aa00 100644 --- a/references/html-template.md +++ b/references/html-template.md @@ -126,7 +126,25 @@ Every generated HTML file **must** comply with these rules: var current = 0; var total = slides.length; + // True when an overlay owns navigation. Authors mark custom overlays + // with data-blocks-nav; the skill's own lightbox is covered with no wiring. + function navBlocked() { + return !!(document.querySelector('.diagram-lightbox.active') || + document.querySelector('[data-blocks-nav]:not([hidden])')); + } + + // Restart the staggered entry animations on the current slide. Standalone + // so the next, prev, keyboard, and dot contract stays untouched. + function replayActiveSlide() { + var s = slides[current]; + if (!s) return; + s.classList.remove('active'); + void s.offsetWidth; // force reflow so the re-add restarts the animations + s.classList.add('active'); + } + function goTo(index) { + if (navBlocked()) return; if (index < 0 || index >= total || index === current) return; current = index; slides.forEach(function(s, i) { @@ -163,20 +181,46 @@ Every generated HTML file **must** comply with these rules: if (e.key === 'End') { e.preventDefault(); goTo(total - 1); } }); + // True when the target sits inside an element that can scroll under the + // pointer, so wheel and swipe deltas belong to the content, not the deck. + function inScrollable(target) { + var el = target; + while (el && el !== document.body) { + if (el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight) return true; + el = el.parentElement; + } + return false; + } + // Touch/swipe navigation - var touchStart = 0; - document.addEventListener('touchstart', function(e) { touchStart = e.touches[0].clientX; }); + var touchX = 0, touchY = 0, touchT = 0; + document.addEventListener('touchstart', function(e) { + touchX = e.touches[0].clientX; + touchY = e.touches[0].clientY; + touchT = Date.now(); + }); document.addEventListener('touchend', function(e) { - var diff = touchStart - e.changedTouches[0].clientX; - if (Math.abs(diff) > 50) { diff > 0 ? next() : prev(); } + if (e.changedTouches.length > 1) return; // ignore multi-touch gestures + // Let interactive and flippable targets keep their own gestures. + if (e.target.closest('.flip-card, a, button, input, textarea, select')) return; + var dx = e.changedTouches[0].clientX - touchX; + var dy = e.changedTouches[0].clientY - touchY; + if (Math.abs(dx) <= Math.abs(dy)) return; // require horizontal dominance + if (Math.abs(dx) < 50) return; // require a deliberate distance + if (Date.now() - touchT > 600) return; // reject slow drags + dx < 0 ? next() : prev(); }); // Mouse wheel navigation var wheelCD = false; document.addEventListener('wheel', function(e) { + if (inScrollable(e.target)) return; // let scrollable content keep the delta + if (Math.abs(e.deltaX) < 10 && Math.abs(e.deltaY) < 10) return; // ignore noise if (wheelCD) return; wheelCD = true; setTimeout(function() { wheelCD = false; }, 600); - if (e.deltaY > 0 || e.deltaX > 0) next(); else prev(); + // Navigate on the dominant axis only, so a diagonal scroll never reverses. + var delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + delta > 0 ? next() : prev(); }, {passive: true}); // Nav dots (generate) @@ -247,6 +291,7 @@ Every presentation must include these **global functions**: 1. **`goTo(index)`** — Navigate to slide at index. Toggle `.active` class. 2. **`next()`** — Go to next slide. 3. **`prev()`** — Go to previous slide. +4. **`replayActiveSlide()`** - Restart the entry animations on the current slide without changing slides. Removes `.active`, forces a reflow, then re-adds `.active`. These must be plain functions on `window`, not inside an IIFE, class, or module. diff --git a/references/presentation-layer.md b/references/presentation-layer.md index b74a593f..347eac60 100644 --- a/references/presentation-layer.md +++ b/references/presentation-layer.md @@ -95,6 +95,19 @@ The presentation must support: Build navigation inline following the pattern in [html-template.md](html-template.md). Generate `goTo()`, `next()`, `prev()` as global functions, plus speaker notes console logging on each slide change. +### Public functions + +| Function | Purpose | +|----------|---------| +| `goTo(index)` | Navigate to the slide at `index`. Toggles the `.active` class and updates the chrome. | +| `next()` | Go to the next slide. | +| `prev()` | Go to the previous slide. | +| `replayActiveSlide()` | Restart the entry animations on the current slide without changing slides. Removes `.active`, forces a reflow, then re-adds `.active`. Useful after a slide returns from an overlay or a rotation. | + +Navigation is suppressed while an overlay is open. `goTo()` returns early when a `.diagram-lightbox.active` element exists or any element carries `data-blocks-nav` and is not hidden, so arrow keys, the wheel, and swipes do not move the slide behind a modal. Mark custom overlays with `data-blocks-nav` to opt into the same guard. + +The wheel and swipe handlers ignore deltas that originate inside a scrollable element, so wide code blocks and tall cards keep their own scroll. Wheel navigation reads the dominant axis only, and swipe navigation requires horizontal dominance, a minimum distance of about 50px, and a short gesture duration before it advances. + ## Generator Meta Tag Every presentation must include in ``: diff --git a/testing/fixtures/deck-overlay-and-codeblock.html b/testing/fixtures/deck-overlay-and-codeblock.html new file mode 100644 index 00000000..637a0a0f --- /dev/null +++ b/testing/fixtures/deck-overlay-and-codeblock.html @@ -0,0 +1,173 @@ + + + + + + + Nav Hardening Fixture + + + +
+
+
+ +
+
+

Slide One

+

Title placeholder content.

+
+ +
+

Slide Two

+

Scroll the code block below. The deck must not advance.

+
+
1const example = "a very wide placeholder line that overflows the code window so the horizontal scrollbar appears and can be scrolled under the pointer";
+
+
+ +
+

Slide Three

+

Closing placeholder content.

+
+
+ + + + + + +