Fix editorial deck navigation (#2173)

This commit is contained in:
kami 2026-05-19 20:30:30 +09:00 committed by GitHub
parent 716f06cb73
commit d1eb8b7bef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 305 additions and 2 deletions

View file

@ -0,0 +1,74 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { JSDOM, VirtualConsole } from 'jsdom';
import type { DOMWindow } from 'jsdom';
const tasteEditorialExamplePath = fileURLToPath(
new URL('../../../../design-templates/html-ppt-taste-editorial/example.html', import.meta.url),
);
function setupTasteEditorialDeck() {
const html = readFileSync(tasteEditorialExamplePath, 'utf8');
const dom = new JSDOM(html, {
pretendToBeVisual: true,
runScripts: 'dangerously',
url: 'https://example.test/taste-editorial.html',
virtualConsole: new VirtualConsole(),
});
return dom;
}
function activeSlideIndex(win: DOMWindow) {
const slides = Array.from(win.document.querySelectorAll<HTMLElement>('.deck > .slide'));
return slides.findIndex((slide) => slide.classList.contains('active'));
}
function fireTouch(win: DOMWindow, startX: number, endX: number) {
const start = new win.Event('touchstart', { bubbles: true, cancelable: true });
Object.defineProperty(start, 'touches', {
value: [{ clientX: startX, clientY: 120 }],
});
win.dispatchEvent(start);
const end = new win.Event('touchend', { bubbles: true, cancelable: true });
Object.defineProperty(end, 'changedTouches', {
value: [{ clientX: endX, clientY: 124 }],
});
win.dispatchEvent(end);
}
describe('design template deck navigation', () => {
it('wires the taste editorial example to the deck input contract', () => {
const dom = setupTasteEditorialDeck();
const { window: win } = dom;
const dots = Array.from(win.document.querySelectorAll<HTMLButtonElement>('#deck-nav .dot'));
expect(win.document.querySelectorAll('.deck > .slide')).toHaveLength(10);
expect(activeSlideIndex(win)).toBe(0);
expect(dots).toHaveLength(10);
expect(dots[0]?.classList.contains('active')).toBe(true);
win.dispatchEvent(new win.KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'ArrowRight',
}));
expect(activeSlideIndex(win)).toBe(1);
win.dispatchEvent(new win.WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 90,
}));
expect(activeSlideIndex(win)).toBe(2);
fireTouch(win, 500, 360);
expect(activeSlideIndex(win)).toBe(3);
dots[6]?.dispatchEvent(new win.MouseEvent('click', { bubbles: true, cancelable: true }));
expect(activeSlideIndex(win)).toBe(6);
expect(dots[6]?.classList.contains('active')).toBe(true);
expect(dots[6]?.getAttribute('aria-current')).toBe('true');
});
});

View file

@ -32,3 +32,31 @@ full split.
gallery has something to preview.
3. Optionally drop additional baked samples under `examples/<key>.html`
to surface them as derived `<parent>:<key>` cards.
## Deck preview navigation contract
Any template with `od.mode: deck` must make its baked `example.html`
usable inside the gallery iframe without relying on the host app to add
navigation. Use a shared deck runtime where one is available; otherwise
ship a tiny local runtime with the same minimum behavior.
- **Keyboard:** `ArrowRight` / `ArrowDown` / `PageDown` / `Space` move to
the next slide; `ArrowLeft` / `ArrowUp` / `PageUp` move to the previous
slide; `Home` and `End` jump to the first and last slide. Ignore events
from inputs, selects, textareas, and editable regions.
- **Wheel / trackpad:** accumulated `deltaX + deltaY` past a small threshold
moves exactly one slide, then resets quickly so a single gesture does not
overshoot.
- **Touch:** a horizontal swipe of roughly 50px or more, greater than the
vertical movement, moves previous / next.
- **Dots:** render one clickable button per slide, update the active dot on
every navigation path, and mark it with `aria-current="true"`.
- **Active slide state:** keep the visible slide marked with
`.slide.active`; adding `.is-active` as a compatibility alias is fine.
Open Design's preview bridge reads this state for the host slide counter,
so it must stay in sync with keyboard, wheel, touch, and dot navigation.
- **Iframe safety:** focus the deck on load / pointer interaction so keyboard
navigation works after the gallery preview appears. Avoid
`scrollIntoView()` because it can move the parent page instead of the deck.
- **Fallbacks:** no-script and print output should still expose every slide.
Hide non-active slides only after the runtime has booted.

View file

@ -9,7 +9,7 @@ A 16:9 deck for the briefs that hate neon: investor updates, design reviews, int
## Source
Distilled from [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill) — `skills/minimalist-skill/SKILL.md`. The deck system follows the existing project convention from `skills/html-ppt-pitch-deck/example.html` (each `.slide` is a `100vw × 100vh` section; opened directly, slides stack vertically). See `example.html` in this directory.
Distilled from [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill) — `skills/minimalist-skill/SKILL.md`. The deck system follows the project deck convention: each `.slide` is a `100vw × 100vh` section, the active slide carries `.active` / `.is-active`, and the baked example owns keyboard, wheel, touch, and dot navigation inside the gallery iframe. No-script and print fallbacks keep every slide visible. See `example.html` in this directory.
## Hard rules
@ -47,7 +47,8 @@ Distilled from [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill)
## Motion
- Static-preview fallback: keep every slide visible (already wired by the deck base). When run as a real deck, fade-in at `400ms cubic-bezier(0.16, 1, 0.3, 1)` is plenty.
- Runtime navigation: keyboard, wheel / trackpad, touch swipe, and dot buttons must all update the same active slide state. Keep the fade at roughly `400ms cubic-bezier(0.16, 1, 0.3, 1)`.
- Static / print fallback: keep every slide visible when the runtime has not booted or the document is printed.
- No translate, no blur, no auto-advance.
## Pre-flight
@ -58,3 +59,4 @@ Distilled from [Leonxlnx/taste-skill](https://github.com/Leonxlnx/taste-skill)
- [ ] Every slide has eyebrow + section number + page number
- [ ] At least one hairline-grid table or comparison module
- [ ] No drop shadows, no gradients, no emojis, no banned fonts
- [ ] Keyboard, wheel / trackpad, touch swipe, and dot navigation all move one slide and keep the active dot / active slide in sync

View file

@ -47,6 +47,75 @@
page-break-after: always;
}
.slide + .slide { border-top: 1px solid var(--hairline); }
body.deck-nav-ready { overflow: hidden; }
body.deck-nav-ready .deck {
height: 100vh;
overflow: hidden;
}
body.deck-nav-ready .slide {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
transition: opacity 420ms var(--ease);
}
body.deck-nav-ready .slide.active {
opacity: 1;
pointer-events: auto;
}
body.deck-nav-ready .slide + .slide { border-top: 0; }
.deck-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
z-index: 40;
background: var(--hairline-soft);
}
.deck-progress span {
display: block;
width: 0;
height: 100%;
background: var(--accent);
transition: width 220ms var(--ease);
}
#deck-nav {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 40;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 11px;
border: 1px solid rgba(26, 26, 25, 0.12);
border-radius: 999px;
background: rgba(251, 251, 250, 0.72);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
#deck-nav .dot {
width: 7px;
height: 7px;
border: 0;
border-radius: 999px;
padding: 0;
background: rgba(26, 26, 25, 0.24);
cursor: pointer;
transition: width 220ms var(--ease), background 220ms var(--ease), transform 220ms var(--ease);
}
#deck-nav .dot:hover { transform: scale(1.2); background: rgba(26, 26, 25, 0.42); }
#deck-nav .dot.active {
width: 22px;
background: var(--accent);
}
#deck-nav .dot:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* Slide chrome */
.meta-row {
@ -295,6 +364,16 @@
/* Print / preview */
@media print {
.slide { height: auto; min-height: 100vh; page-break-after: always; }
body.deck-nav-ready { overflow: visible; }
body.deck-nav-ready .deck { height: auto; overflow: visible; }
body.deck-nav-ready .slide {
position: relative;
inset: auto;
opacity: 1;
pointer-events: auto;
}
.deck-progress,
#deck-nav { display: none; }
}
</style>
</head>
@ -486,5 +565,125 @@
</section>
</div>
<div class="deck-progress" id="deck-progress" aria-hidden="true"><span></span></div>
<nav id="deck-nav" aria-label="Slide navigation"></nav>
<script>
(function () {
var deck = document.querySelector('.deck');
if (!deck) return;
var slides = Array.prototype.filter.call(deck.children, function (el) {
return el.classList && el.classList.contains('slide');
});
var nav = document.getElementById('deck-nav');
var progress = document.querySelector('#deck-progress span');
if (!slides.length || !nav || !progress) return;
var idx = 0;
var wheelAcc = 0;
var wheelTimer = null;
var touchX = 0;
var touchY = 0;
document.body.classList.add('deck-nav-ready');
document.body.setAttribute('tabindex', '-1');
document.body.style.outline = 'none';
slides.forEach(function (_, i) {
var dot = document.createElement('button');
dot.className = 'dot';
dot.type = 'button';
dot.setAttribute('aria-label', 'Go to slide ' + (i + 1));
dot.addEventListener('click', function () { go(i); });
nav.appendChild(dot);
});
function clamp(n) {
return Math.max(0, Math.min(slides.length - 1, n));
}
function go(n) {
idx = clamp(n);
slides.forEach(function (slide, i) {
var active = i === idx;
slide.classList.toggle('active', active);
slide.classList.toggle('is-active', active);
slide.setAttribute('aria-hidden', active ? 'false' : 'true');
});
Array.prototype.forEach.call(nav.querySelectorAll('.dot'), function (dot, i) {
var active = i === idx;
dot.classList.toggle('active', active);
if (active) dot.setAttribute('aria-current', 'true');
else dot.removeAttribute('aria-current');
});
progress.style.width = (((idx + 1) / slides.length) * 100) + '%';
}
function shouldIgnoreKeyTarget(target) {
if (!target || !target.tagName) return false;
var tag = target.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable;
}
function onKey(e) {
if (e.__tasteDeckHandled || shouldIgnoreKeyTarget(e.target)) return;
var next = null;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') next = idx + 1;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') next = idx - 1;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = slides.length - 1;
if (next === null) return;
e.__tasteDeckHandled = true;
e.preventDefault();
go(next);
}
function onWheel(e) {
var delta = e.deltaY + e.deltaX;
if (Math.abs(delta) < 1) return;
e.preventDefault();
wheelAcc += delta;
if (Math.abs(wheelAcc) > 60) {
go(idx + (wheelAcc > 0 ? 1 : -1));
wheelAcc = 0;
}
clearTimeout(wheelTimer);
wheelTimer = setTimeout(function () { wheelAcc = 0; }, 150);
}
function onTouchStart(e) {
if (!e.touches || !e.touches.length) return;
touchX = e.touches[0].clientX;
touchY = e.touches[0].clientY;
}
function onTouchEnd(e) {
if (!e.changedTouches || !e.changedTouches.length) return;
var dx = e.changedTouches[0].clientX - touchX;
var dy = e.changedTouches[0].clientY - touchY;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
go(idx + (dx < 0 ? 1 : -1));
}
}
function focusDeck() {
try {
window.focus();
document.body.focus({ preventScroll: true });
} catch (_) {}
}
window.addEventListener('keydown', onKey, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('wheel', onWheel, { passive: false });
window.addEventListener('touchstart', onTouchStart, { passive: true });
window.addEventListener('touchend', onTouchEnd, { passive: true });
document.addEventListener('mousedown', focusDeck);
window.addEventListener('load', focusDeck);
go(0);
focusDeck();
})();
</script>
</body>
</html>