← Files ClaraARCHIVED FILE
skills/html-deck/assets/deck-engine/deck.js
13.7 KB · Oct 2, 2026 · 00:29 UTC
(() => {
const stage = document.querySelector('.deck-stage');
const slides = [...stage.querySelectorAll(':scope > .slide')];
const chapterNav = document.getElementById('chapterNav');
const dots = document.getElementById('deckDots');
const progressBar = document.getElementById('progressBar');
const overviewGrid = document.getElementById('overviewGrid');
const overviewOverlay = document.getElementById('overviewOverlay');
const helpOverlay = document.getElementById('helpOverlay');
const notesPanel = document.getElementById('notesPanel');
const controls = document.querySelector('.deck-controls');
let current = 0;
let wheelLocked = false;
let touchStart = null;
let idleTimer = null;
let pointerFrame = null;
let previousFocus = null;
let printState = null;
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const slideTitle = (slide) => slide.dataset.slideTitle || slide.dataset.title || slide.querySelector('h1,h2,h3')?.textContent?.trim() || slide.id;
const isInteractive = (target) => Boolean(target?.closest?.('button, a, input, textarea, select, [contenteditable="true"]'));
const isTextEntry = (target) => Boolean(target?.closest?.('input, textarea, select, [contenteditable="true"]'));
function fragmentGroups(slide) {
const fragments = [...slide.querySelectorAll('[data-fragment]')];
const groups = new Map();
fragments.forEach((fragment, index) => {
const parsed = Number.parseInt(fragment.dataset.fragment, 10);
const step = Number.isFinite(parsed) && parsed > 0 ? parsed : index + 1;
if (!groups.has(step)) groups.set(step, []);
groups.get(step).push(fragment);
});
return [...groups.entries()].sort((a, b) => a[0] - b[0]);
}
function nextFragment(slide) {
const group = fragmentGroups(slide).find(([, fragments]) => fragments.some((fragment) => !fragment.classList.contains('is-shown')));
if (!group) return false;
group[1].forEach((fragment) => fragment.classList.add('is-shown'));
return true;
}
function previousFragment(slide) {
const groups = fragmentGroups(slide).filter(([, fragments]) => fragments.some((fragment) => fragment.classList.contains('is-shown')));
const group = groups.at(-1);
if (!group) return false;
group[1].forEach((fragment) => fragment.classList.remove('is-shown'));
return true;
}
function setFragmentState(slide, mode) {
slide.querySelectorAll('[data-fragment]').forEach((fragment) => fragment.classList.toggle('is-shown', mode === 'all'));
}
function updateNotes(slide) {
const source = slide.querySelector('.speaker-notes');
notesPanel.querySelector('p').textContent = source?.textContent?.trim() || '';
}
function updateChrome(slide) {
const chapter = slide.dataset.chapter || '';
chapterNav.querySelectorAll('button').forEach((button) => button.classList.toggle('is-active', button.dataset.chapter === chapter));
dots.querySelectorAll('button').forEach((button, index) => {
button.classList.toggle('is-active', index === current);
button.classList.toggle('is-dark', slide.dataset.tone === 'dark');
button.setAttribute('aria-current', index === current ? 'true' : 'false');
});
overviewGrid.querySelectorAll('button').forEach((button, index) => button.classList.toggle('is-active', index === current));
progressBar.style.transform = `scaleX(${(current + 1) / slides.length})`;
}
function announceSlide(slide) {
document.dispatchEvent(new CustomEvent('clara:slidechange', {
detail: {
slideId: slide.id,
slideTitle: slideTitle(slide),
slideIndex: current,
},
}));
}
function showSlide(index, options = {}) {
const next = clamp(index, 0, slides.length - 1);
const direction = next < current ? 'backward' : 'forward';
current = next;
slides.forEach((slide, slideIndex) => {
const active = slideIndex === current;
slide.classList.toggle('is-active', active);
slide.classList.toggle('is-before', slideIndex < current);
slide.classList.toggle('is-after', slideIndex > current);
slide.setAttribute('aria-hidden', active ? 'false' : 'true');
slide.toggleAttribute('inert', !active);
slide.dataset.active = active ? 'true' : 'false';
if (active && options.resetFragments !== false) {
setFragmentState(slide, direction === 'backward' ? 'all' : 'none');
}
});
const slide = slides[current];
updateChrome(slide);
updateNotes(slide);
const targetHash = `#${encodeURIComponent(slide.id)}`;
if (location.hash !== targetHash) history.replaceState(null, '', targetHash);
announceSlide(slide);
wakeChrome();
}
function advance() {
if (nextFragment(slides[current])) return;
if (current < slides.length - 1) showSlide(current + 1);
}
function retreat() {
if (previousFragment(slides[current])) return;
if (current > 0) showSlide(current - 1);
}
function goToHash() {
const id = decodeURIComponent(location.hash.slice(1));
const index = slides.findIndex((slide) => slide.id === id);
if (index >= 0 && index !== current) showSlide(index);
}
function buildChrome() {
const chapters = [];
slides.forEach((slide, index) => {
const title = slideTitle(slide);
const chapter = slide.dataset.chapter || `chapter-${index + 1}`;
const label = slide.dataset.chapterLabel || chapter.replace(/[-_]+/g, ' ');
if (!chapters.some((item) => item.id === chapter)) chapters.push({ id: chapter, label, target: slide.id });
const dot = document.createElement('button');
dot.type = 'button';
dot.setAttribute('aria-label', `Go to ${title}`);
dot.addEventListener('click', () => showSlide(index));
dots.appendChild(dot);
const card = document.createElement('button');
card.type = 'button';
card.className = 'overview-card';
card.innerHTML = `<span>${label}</span><strong>${title}</strong>`;
card.addEventListener('click', () => {
closeOverlays();
showSlide(index);
});
overviewGrid.appendChild(card);
});
chapters.forEach((chapter) => {
const button = document.createElement('button');
button.type = 'button';
button.dataset.chapter = chapter.id;
button.textContent = chapter.label;
button.addEventListener('click', () => {
const index = slides.findIndex((slide) => slide.id === chapter.target);
if (index >= 0) showSlide(index);
});
chapterNav.appendChild(button);
});
}
function focusableWithin(container) {
return [...container.querySelectorAll('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])')].filter((element) => !element.disabled && element.offsetParent !== null);
}
function openOverlay(overlay) {
closeOverlays(false);
previousFocus = document.activeElement;
overlay.hidden = false;
document.body.classList.remove('show-notes');
requestAnimationFrame(() => focusableWithin(overlay)[0]?.focus());
}
function closeOverlays(restoreFocus = true) {
[overviewOverlay, helpOverlay].forEach((overlay) => { overlay.hidden = true; });
if (restoreFocus && previousFocus instanceof HTMLElement) previousFocus.focus();
previousFocus = null;
}
function trapOverlayFocus(event) {
const overlay = [overviewOverlay, helpOverlay].find((candidate) => !candidate.hidden);
if (!overlay || event.key !== 'Tab') return;
const items = focusableWithin(overlay);
if (!items.length) return;
const first = items[0];
const last = items.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
async function toggleFullscreen() {
if (!document.fullscreenElement) await document.documentElement.requestFullscreen?.();
else await document.exitFullscreen?.();
}
function wakeChrome() {
document.body.classList.remove('ui-idle');
window.clearTimeout(idleTimer);
idleTimer = window.setTimeout(() => {
if (!document.body.classList.contains('show-notes') && overviewOverlay.hidden && helpOverlay.hidden) document.body.classList.add('ui-idle');
}, 2600);
}
function updateSpotlight(event) {
if (window.matchMedia('(pointer: coarse), (prefers-reduced-motion: reduce)').matches) return;
if (pointerFrame) return;
pointerFrame = requestAnimationFrame(() => {
const rect = stage.getBoundingClientRect();
const x = clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100);
const y = clamp(((event.clientY - rect.top) / rect.height) * 100, 0, 100);
document.documentElement.style.setProperty('--mx', `${x}%`);
document.documentElement.style.setProperty('--my', `${y}%`);
pointerFrame = null;
});
}
function handleKey(event) {
trapOverlayFocus(event);
if (event.defaultPrevented || isTextEntry(event.target)) return;
const key = event.key.toLowerCase();
if (isInteractive(event.target) && (key === ' ' || key === 'enter')) return;
if (['arrowright', 'arrowdown', 'pagedown', ' '].includes(key)) { event.preventDefault(); advance(); }
else if (['arrowleft', 'arrowup', 'pageup'].includes(key)) { event.preventDefault(); retreat(); }
else if (key === 'home') { event.preventDefault(); showSlide(0); }
else if (key === 'end') { event.preventDefault(); showSlide(slides.length - 1); }
else if (key === 'o') openOverlay(overviewOverlay);
else if (key === 'n') { document.body.classList.toggle('show-notes'); closeOverlays(false); wakeChrome(); }
else if (key === 'f') toggleFullscreen();
else if (key === 'h' || key === '?') openOverlay(helpOverlay);
else if (key === 'escape') { closeOverlays(); document.body.classList.remove('show-notes'); }
}
function beforePrint() {
printState = slides.map((slide) => [...slide.querySelectorAll('[data-fragment]')].map((fragment) => fragment.classList.contains('is-shown')));
slides.forEach((slide) => setFragmentState(slide, 'all'));
}
function afterPrint() {
if (!printState) return;
slides.forEach((slide, slideIndex) => {
slide.querySelectorAll('[data-fragment]').forEach((fragment, fragmentIndex) => fragment.classList.toggle('is-shown', Boolean(printState[slideIndex]?.[fragmentIndex])));
});
printState = null;
}
buildChrome();
const hashIndex = slides.findIndex((slide) => slide.id === decodeURIComponent(location.hash.slice(1)));
current = hashIndex >= 0 ? hashIndex : Math.max(0, slides.findIndex((slide) => slide.classList.contains('is-active') || slide.getAttribute('aria-hidden') === 'false'));
showSlide(current, { resetFragments: false });
document.getElementById('prevBtn').addEventListener('click', retreat);
document.getElementById('nextBtn').addEventListener('click', advance);
document.getElementById('overviewBtn').addEventListener('click', () => openOverlay(overviewOverlay));
document.getElementById('helpBtn').addEventListener('click', () => openOverlay(helpOverlay));
document.getElementById('fullscreenBtn').addEventListener('click', toggleFullscreen);
document.querySelectorAll('[data-close-overlay]').forEach((button) => button.addEventListener('click', closeOverlays));
[overviewOverlay, helpOverlay].forEach((overlay) => overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) closeOverlays(); }));
document.addEventListener('keydown', handleKey);
window.addEventListener('hashchange', goToHash);
window.addEventListener('beforeprint', beforePrint);
window.addEventListener('afterprint', afterPrint);
document.addEventListener('pointermove', (event) => { wakeChrome(); updateSpotlight(event); }, { passive: true });
document.addEventListener('touchstart', (event) => {
wakeChrome();
if (isInteractive(event.target) || event.touches.length !== 1) return;
touchStart = { x: event.touches[0].clientX, y: event.touches[0].clientY };
}, { passive: true });
document.addEventListener('touchend', (event) => {
if (!touchStart || !event.changedTouches.length) return;
const dx = event.changedTouches[0].clientX - touchStart.x;
const dy = event.changedTouches[0].clientY - touchStart.y;
touchStart = null;
if (Math.abs(dx) > 54 && Math.abs(dx) > Math.abs(dy) * 1.35) (dx < 0 ? advance : retreat)();
}, { passive: true });
document.addEventListener('wheel', (event) => {
if (wheelLocked || isInteractive(event.target) || Math.abs(event.deltaY) < 18) return;
wheelLocked = true;
(event.deltaY > 0 ? advance : retreat)();
window.setTimeout(() => { wheelLocked = false; }, 520);
}, { passive: true });
controls.addEventListener('pointerenter', wakeChrome);
wakeChrome();
})();
SHA-256: 5f2307d9e5f7f04f1bc77c37c17b22df4f298f1f7b6769f3645d1de4f06fdc6b