From 7e2542fdbe6ac45bbf0702f2425fee6f894adb47 Mon Sep 17 00:00:00 2001 From: Mitch Date: Tue, 9 Jun 2026 11:39:38 +1000 Subject: [PATCH] feat(runtime): add URL deep-linking and hashchange navigation Reload always landed on slide one, links could not target a slide, and a deep-linked chart slide rendered blank because the chart and notes bootstrap hardcoded slide zero. Add #N deep-linking with a hashchange listener and history.replaceState, and bootstrap charts and notes from the current slide. Co-Authored-By: Leslie Barbara Knope (Claude Opus 4.8 (1M context)) --- assets/slides-runtime.js | 24 +++++++++++++++++++----- references/html-template.md | 19 +++++++++++++++++-- references/presentation-layer.md | 4 ++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/assets/slides-runtime.js b/assets/slides-runtime.js index 9af41234..970a864e 100644 --- a/assets/slides-runtime.js +++ b/assets/slides-runtime.js @@ -23,9 +23,17 @@ const slides = document.querySelectorAll('.slide'); const progress = document.getElementById('progress'); const counter = document.getElementById('counter'); const slideNav = document.getElementById('slideNav'); -let current = 0; const total = slides.length; +// Parse a one-based "#N" hash into a zero-based index, clamped to range. +function slideFromHash() { + const n = parseInt((location.hash || '').replace('#', ''), 10); + if (isNaN(n)) return 0; + return Math.min(Math.max(n, 1), total) - 1; +} + +let current = slideFromHash(); + slides.forEach((_, i) => { const dot = document.createElement('div'); dot.className = 'slide-nav-dot' + (i===0?' active':''); @@ -44,6 +52,7 @@ function goTo(index) { const prev = current; current = index; slides.forEach((s,i) => s.classList.toggle('active', i===current)); + history.replaceState(null, '', '#' + (current + 1)); updateUI(); showSpeakerNotes(current); @@ -81,6 +90,11 @@ document.addEventListener('wheel', (e) => { }, {passive:true}); updateUI(); +// Reconcile the active class with a deep-linked "current" so the right slide shows on load. +slides.forEach((s,i) => s.classList.toggle('active', i===current)); + +// The "index === current" guard in goTo prevents this from looping back on its own replaceState. +window.addEventListener('hashchange', () => goTo(slideFromHash())); // ========================================= // Chart.js Integration @@ -202,10 +216,10 @@ function destroyChart(canvasId) { } } -// Initialize charts on first slide +// Initialize charts on the slide that loads first, which may be a deep-linked one. if (typeof Chart !== 'undefined') { setTimeout(() => { - slides[0].querySelectorAll('.chart-container canvas').forEach(c => createChart(c.id)); + slides[current].querySelectorAll('.chart-container canvas').forEach(c => createChart(c.id)); }, 400); } @@ -247,5 +261,5 @@ function showSpeakerNotes(index) { } } -// Show notes for first slide on load -setTimeout(function() { showSpeakerNotes(0); }, 500); +// Show notes for the slide that loads first, which may be a deep-linked one. +setTimeout(function() { showSpeakerNotes(current); }, 500); diff --git a/references/html-template.md b/references/html-template.md index d48d56c1..c43dddde 100644 --- a/references/html-template.md +++ b/references/html-template.md @@ -123,15 +123,24 @@ Every generated HTML file **must** comply with these rules: NAVIGATION (REQUIRED — global functions) =========================================== */ var slides = document.querySelectorAll('.slide'); - var current = 0; var total = slides.length; + // Parse a one-based "#N" hash into a zero-based index, clamped to range. + function slideFromHash() { + var n = parseInt((location.hash || '').replace('#', ''), 10); + if (isNaN(n)) return 0; + return Math.min(Math.max(n, 1), total) - 1; + } + + var current = slideFromHash(); + function goTo(index) { if (index < 0 || index >= total || index === current) return; current = index; slides.forEach(function(s, i) { s.classList.toggle('active', i === current); }); + history.replaceState(null, '', '#' + (current + 1)); slides[current].scrollIntoView({ behavior: 'smooth' }); updateUI(); showSpeakerNotes(current); @@ -191,6 +200,11 @@ Every generated HTML file **must** comply with these rules: } updateUI(); + // Reconcile the active class with a deep-linked "current" so the right slide shows on load. + slides.forEach(function(s, i) { s.classList.toggle('active', i === current); }); + + // The "index === current" guard in goTo prevents this from looping back on its own replaceState. + window.addEventListener('hashchange', function() { goTo(slideFromHash()); }); /* =========================================== SPEAKER NOTES (Console) @@ -223,7 +237,8 @@ Every generated HTML file **must** comply with these rules: console.log('%c\u270f\ufe0f Want to update the notes? See htmlslides.com/blog/update-inline-notes.html', 'font-size:10px;color:#8b949e;'); } - setTimeout(function() { showSpeakerNotes(0); }, 500); + // Show notes for the slide that loads first, which may be a deep-linked one. + setTimeout(function() { showSpeakerNotes(current); }, 500); /* =========================================== OPTIONAL ENHANCEMENTS diff --git a/references/presentation-layer.md b/references/presentation-layer.md index b74a593f..e29629a5 100644 --- a/references/presentation-layer.md +++ b/references/presentation-layer.md @@ -95,6 +95,10 @@ 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. +## Deep-Linking + +Slides are addressable by a one-based `#N` URL hash. Loading `#5` opens the fifth slide directly, with its charts and speaker notes rendered for that slide rather than the first. Each navigation writes the current slide back to the hash, so a reload returns to the same slide and the browser Back button steps through the slides already visited. An out-of-range or malformed hash clamps to a valid slide, and a deck opened with no hash starts on slide one. + ## Generator Meta Tag Every presentation must include in ``: