ontoref-outreach/scripts/decks/lib/build-body.mjs
Jesús Pérez da93544de3 decks: the slides stop being pixels — Slidev's own render, captured and re-homed
The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.

The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
utilities, theme rules and <style scoped> blocks. Re-implementing that is
re-implementing a renderer, in any language. So Slidev renders (its own /print route,
the one `slidev export` drives) and we capture what it produced: the DOM, and the CSS
the browser actually applied.

Measured on the served page: 0 → 2152 indexable words, 16/16 slides reachable, 0
third-party origins added, 0 broken references.

Twelve traps, none of which raised an error:
  · The CSS must be asked of the BROWSER (17 stylesheets; index.html links 2).
  · Collapsing :root/html/body imports the SPA's viewport cage — it clipped the deck
    at 900px and swallowed 14 of 16 slides, silently.
  · UnoCSS's preflight, scoped, kills the glossary underline it was supposed to enable.
  · :not() inherits its argument's specificity — the boundary guard grew with every
    exemption until it reverted the slide scaling. :not(:where(…)) contributes zero.
  · scale(calc(100cqw / 980)) is invalid CSS and is dropped in silence; on a phone the
    box was 328px while the content stayed 980px and two thirds of every slide was cut.
  · The site's markdown renderer destroys an inline <script>: curly quotes, injected
    <p>, &amp;&amp;. CSS and JS go to external files — correctness, not optimisation.
  · The 980x552 frame CUT 12 of 16 slides (Slidev cuts them too; nobody had seen it,
    because the slides were PNGs of themselves).
  · transform: translateY(0) — an IDENTITY transform — makes <main> the containing
    block for position:fixed, and the zoom modal landed at top = -221px.

Two views over one DOM (slider default, stacked reads with Ctrl+F), an index derived
from each slide's own H1, a sticky rail, zoom, and Slidev's own copy buttons revived —
they shipped in the captured DOM at 0x0, unclickable by any human.

Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
2026-07-14 19:41:51 +01:00

575 lines
30 KiB
JavaScript

// Build the deck's reading surface: two views over ONE DOM.
//
// slider — one slide at a time, arrows / Space / index. The default (decided 2026-07-14).
// read — every slide stacked as a card. Ctrl+F works, text is selectable, the page
// prints, a screen reader walks it.
//
// Both views share the same nodes: the capture already gives one .print-slide-container
// per slide, which is the structural equivalent of the old carousel's <img>. Every slide
// is ALWAYS in the DOM (so it is always indexable); the mode only changes what the reader
// reaches with Ctrl+F — 2.1k words stacked, ~30 in slider. That is the price of the
// default, and it is stated rather than buried.
export function buildBody(deckHtml, { id, count, W, H, t }) {
let n = 0
let body = deckHtml.replace(/<div([^>]*class="print-slide-container"[^>]*)>/g, (_, attrs) => {
const i = n++
return `<figure class="deck-slide" data-slide="${i}"${i === 0 ? ' data-active' : ''}><div${attrs}>`
})
// Each .print-slide-container is a top-level sibling, so the figure closes right before
// the next one opens, and once at the end.
const parts = body.split('<figure class="deck-slide"')
body = parts[0] + parts.slice(1).map(p => ('<figure class="deck-slide"' + p).trimEnd() + '</figure>').join('')
if (n !== count) throw new Error(`envolví ${n} slides pero la captura contó ${count}`)
return { html: chrome(body, { n, t }), css: css(W, H), js: js(W, H), slides: n }
}
// TWO layers, deliberately.
//
// .deck-view — the site's UI around the deck: bar, index, switcher, backdrop. It
// belongs to the PAGE, so it inherits the page's colours and follows the
// site's theme.
// .deck-read — the boundary. It wraps ONLY the captured DOM, and carries `dark`
// because the deck's own world is dark.
//
// Nesting the chrome inside .deck-read (as this first did) makes it inherit the DECK's
// palette — near-white text — while it is painted on the SITE's background: an index
// nobody can read on a light page. The boundary wraps what was captured, never the
// interface we build around it.
const chrome = (body, { n, t }) => `
<div class="deck-view" id="deck-view" data-mode="read" data-count="${n}" tabindex="0"
data-t-slide="${t.slide}" data-t-zoom="${t.zoom}"
aria-label="${t.aria.replace('%n', n)}">
<div class="deck-bar">
<div class="deck-modes" role="group" aria-label="Modo de vista">
<button type="button" data-set-mode="read" aria-pressed="true">${t.read}</button>
<button type="button" data-set-mode="slider" aria-pressed="false">${t.slider}</button>
</div>
<button type="button" class="deck-toc-toggle" aria-expanded="true" aria-controls="deck-toc">${t.index}</button>
<span class="deck-count">1 / ${n}</span>
<div class="deck-navs">
<button type="button" class="deck-nav" data-dir="-1" aria-label="${t.prev}">&larr;</button>
<button type="button" class="deck-nav" data-dir="1" aria-label="${t.next}">&rarr;</button>
</div>
</div>
<nav class="deck-toc" id="deck-toc" aria-label="${t.index_head}">
<p class="deck-toc-head">${t.index_head}${n} ${t.slides}</p>
<ol class="deck-toc-list"></ol>
</nav>
<div class="deck-read dark"><div class="deck-slides">${body}</div></div>
<div class="deck-backdrop"></div>
</div>`
const css = (W, H) => `
.deck-slides { display: flex; flex-direction: column; gap: 1.5rem; }
.deck-slide {
container-type: inline-size;
position: relative; width: 100%;
/* NOT aspect-ratio: 980/552. Slidev's frame is 552px tall and 12 of this deck's 16 slides
hold MORE than that — up to 672px — so a rigid 16:9 box silently cuts the bottom off
three quarters of the deck. Slidev clips them too; nobody noticed because until now the
slides were PNGs of themselves. A reading surface may not lose content: the card is as
tall as the slide's real content, measured (--deck-h) and scaled (--deck-k). */
height: calc(var(--deck-h, ${H}) * var(--deck-k, 1) * 1px);
overflow: hidden; border-radius: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.25), 0 8px 24px rgba(0,0,0,.18);
}
/* The card is dark because THE DECK is dark, and not by accident: its source declares
colorSchema:dark, so Slidev never compiles a light palette at all. A light/dark toggle
here was tried and reverted — removing the .dark class turned the card white while the
deck's text stayed rgb(232,232,232): light on white, unreadable. Slidev's 37 .dark rules
exist in the bundle, but they are not what makes THIS deck dark; the theme's base colours
are, unconditionally. And the slides hard-code absolute utilities (text-gray-400,
bg-gray-900) that would not flip with a theme anyway. A light deck means re-authoring the
decks, not toggling a class. The chrome around it still follows the SITE's theme. */
.deck-slide { background: #0b0b0b; border: 1px solid rgba(255,255,255,.10); }
/* The slide is a <figure>, and the site's .prose gives every figure margin: 2em 0. The card
is exempt from the boundary guard on purpose (deck- classes are ours), so that margin lands
on it — and on the zoom modal it pushed the fixed element 32px down, past the bottom of the
viewport on a large screen. Specificity (0,2,0) to beat .prose figure (0,1,1). */
.deck-view .deck-slide { margin: 0; }
.deck-slide::after { color: rgba(255,255,255,.35); background: rgba(0,0,0,.35); }
/* Slidev hard-codes 980x552 inline. The slide has to be SCALED to its box, or the
content is simply clipped: on a 390px phone the box is 328px wide while the content
stays 980px — two thirds of every slide cropped away, with no error anywhere.
NOT scale(calc(100cqw / 980)): scale() demands a <number>, and calc() cannot divide a
length by a length, so that declaration is silently DROPPED. It merely looks right
while the column happens to measure ~980px. The ratio must be computed (ResizeObserver)
and published as a custom property.
Bound to [data-js] for (0,4,0): Slidev's own <style scoped> compiled to
.print-slide-container[data-v-HASH] (0,3,0) and pushes UnoCSS's entire transform stack
— all identity — so a lower-specificity rule here is overridden and never scales. */
.deck-view[data-js] .deck-slide > .print-slide-container {
position: absolute; top: 0; left: 0;
transform: scale(var(--deck-k, 1));
transform-origin: top left;
/* Slidev's own container is height:552px + overflow:hidden — it is the thing that actually
CUTS the slide. Growing our card around it changed nothing visible: the content was still
clipped inside, and a gate that measured getBoundingClientRect saw no problem, because a
rect is reported whether or not an ancestor is hiding it. Unclip the container; our card
is the one that decides the frame now. */
overflow: visible;
/* !important, and only here: the height is an INLINE style on the captured node (552px), and
inline beats every rule but this. The slide's own background paints inside that box, so a
552px box under a taller card leaves a band of the card's colour at the foot. Stretch the
container to the measured height and the slide paints its whole self. */
height: calc(var(--deck-h, 552) * 1px) !important;
}
/* No JS → no ResizeObserver → no scale. Show the slide at native size and let it scroll.
Losing two thirds of every slide is not a graceful degradation. */
.deck-view:not([data-js]) .deck-slide { height: auto; min-height: ${H}px; overflow: auto; }
.deck-slide::after {
content: attr(data-slide-label);
position: absolute; right: .6rem; bottom: .5rem;
font: 500 .7rem/1 ui-monospace, monospace;
padding: .25rem .45rem; border-radius: .3rem; pointer-events: none;
}
/* The chrome sits on the SITE's background and takes the SITE's colours — currentColor
throughout, so it follows the site's own light/dark theme instead of the deck's. */
/* Sticky: with 16 stacked slides the controls are off-screen by slide 2, and a control you
have to scroll back for is a control you do not use.
The background is the page's REAL one, measured. Canvas (the UA system colour) looked
like the elegant choice — it follows color-scheme without coupling to the site's palette
— but it only works if the site ALSO paints with system colours, and this one paints
with its own daisyUI tokens. The result was a black band (rgb(18,18,18)) sitting on a
navy page. A sticky element needs the actual background of what is behind it, and that
can be measured: walk up to the first ancestor with a non-transparent background. */
.deck-bar {
display: flex; align-items: center; gap: 1rem; font-size: .85rem;
position: sticky; top: 0; z-index: 5;
padding: .6rem 0; margin-bottom: .75rem;
background: var(--deck-page-bg, Canvas);
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
}
.deck-toc-toggle {
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
background: transparent; border-radius: .3rem; padding: .35rem .7rem;
cursor: pointer; font: inherit; color: inherit;
}
.deck-toc-toggle:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
.deck-toc-toggle[aria-expanded="true"] { background: color-mix(in srgb, currentColor 12%, transparent); }
.deck-toc-head { margin: 0 0 .5rem; opacity: .6; font-size: .75rem;
text-transform: uppercase; letter-spacing: .06em; }
.deck-modes { display: inline-flex; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: .4rem; overflow: hidden; }
.deck-modes button { border: 0; background: transparent; padding: .35rem .7rem; cursor: pointer; font: inherit; color: inherit; }
.deck-modes button[aria-pressed="true"] { background: #E8A838; color: #1a1a1a; font-weight: 600; }
.deck-count { font-variant-numeric: tabular-nums; opacity: .7; }
.deck-navs { display: inline-flex; gap: .35rem; margin-left: auto; }
.deck-nav { border: 1px solid color-mix(in srgb, currentColor 25%, transparent); background: transparent; border-radius: .3rem;
width: 2rem; height: 2rem; cursor: pointer; font: inherit; color: inherit; }
.deck-nav:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
.deck-toc { margin-bottom: 1rem; font-size: .85rem; }
.deck-view[data-toc="off"] .deck-toc { display: none; }
.deck-toc-list { list-style: none; margin: .5rem 0 0; padding: 0; columns: 2; column-gap: 1.5rem; }
@media (max-width: 42rem) { .deck-toc-list { columns: 1; } }
.deck-toc-list li { break-inside: avoid; margin: 0 0 .15rem; }
.deck-toc-link { display: flex; gap: .5rem; width: 100%; text-align: left; border: 0;
background: transparent; cursor: pointer; font: inherit; color: inherit;
padding: .3rem .4rem; border-radius: .3rem; }
.deck-toc-link:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
.deck-toc-link[aria-current="true"] { background: #E8A838; color: #1a1a1a; font-weight: 600; }
.deck-toc-num { opacity: .5; font-variant-numeric: tabular-nums; min-width: 1.4rem; }
/* The glossary draws INSIDE the slide, and the whole slide is transform-scaled afterwards, so
its 1.5px underline renders at 1.5 * k — measured 0.36px on a phone: a third of a pixel, i.e.
nothing. The marker is a UI affordance, not slide content: it should keep a constant weight on
screen whatever the slide's scale. Compensate with the same --deck-k that scales the slide,
with a floor so a thumbnail-sized slide does not get a comically heavy rule.
The chip's padding and radius are NOT compensated on purpose — they hug the text and should
shrink with it. Only the hairline risks disappearing into a sub-pixel. */
.deck-read .gloss-term {
border-bottom-width: calc(1.5px / max(var(--deck-k, 1), 0.5));
}
.deck-view[data-mode="slider"] .deck-slide { display: none; }
.deck-view[data-mode="slider"] .deck-slide[data-active] { display: block; }
/* ── The index is a sticky rail whenever there is width for it ──────────────────────── */
/* Both modes, not just stacked: a rail you can always see beats a <details> you have to
remember to open. Below 60rem there is no room for a column, so it falls back to the
collapsible block above the deck.
.deck-slides keeps its own gap; in slider mode only one slide is displayed anyway. */
@media (min-width: 60rem) {
.deck-view {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
grid-template-areas: "bar bar" "toc slides";
column-gap: 1.5rem;
}
.deck-view[data-toc="off"] {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "bar" "slides";
}
.deck-view .deck-bar { grid-area: bar; }
.deck-view .deck-read { grid-area: slides; }
.deck-view .deck-toc {
grid-area: toc;
position: sticky; top: 3.5rem; align-self: start; /* bajo la barra pegajosa */
max-height: calc(100vh - 5rem); overflow-y: auto;
margin-bottom: 0; padding-right: .25rem;
}
.deck-view .deck-toc-list { columns: 1; }
/* Stacked, the rail marks where you ARE (it follows the scroll), which is not the same
as where you last clicked — so it is a quiet marker, not a filled button. In slider
mode the current slide IS a selection, and stays filled. */
.deck-view[data-mode="read"] .deck-toc-link[aria-current="true"] {
background: transparent; color: inherit; font-weight: 600;
box-shadow: inset 3px 0 0 #E8A838; border-radius: 0;
}
.deck-view[data-mode="read"] .deck-toc-link[aria-current="true"] .deck-toc-num { opacity: .9; }
/* THE ONE PLACE the projection must reach outside its own boundary. The site wraps every
post in <article class="... overflow-hidden ...">, and an overflow:hidden box IS a
scroll container: position:sticky anchors to it, that box never scrolls, and the
rail rides away with the content (measured: y = -1074px). No descendant can undo an
ancestor overflow, so the rule has to name the ancestor — but :has() keeps it
surgical: it exists only on pages that actually contain a deck, and every other
article on the site is untouched. */
article:has(.deck-view) { overflow: visible; }
}
/* The second place the projection must name the site, and the same kind of trap. The site
puts transform:translateY(0) on .page-content — an IDENTITY transform, the resting state
of its page transition. It moves nothing, and it makes <main> the containing block for every
position:fixed descendant, so the zoom modal was positioned against a scrolled-off <main>
instead of the viewport (measured: top = -221px, above the window).
Neutralised only WHILE zoomed, so the site's page transition is untouched the rest of the
time — and at that instant the transform is identity anyway, so nothing moves. */
main:has(.deck-view[data-zoom]) { transform: none; }
/* Zoom needs no re-render: the slide is a container-query box, so more width means
scale(var(--deck-k)) enlarges it. The text scales as TEXT, not as a bitmap. */
.deck-zoom { position: absolute; right: .6rem; top: .6rem; z-index: 2;
width: 2rem; height: 2rem; padding: 0; display: grid; place-content: center;
border: 1px solid rgba(255,255,255,.18); border-radius: .35rem;
background: rgba(0,0,0,.45); color: #fff; font-size: .9rem; line-height: 1;
cursor: pointer; opacity: 0; transition: opacity .15s; }
.deck-slide:hover .deck-zoom, .deck-zoom:focus-visible { opacity: 1; }
.deck-backdrop { position: fixed; inset: 0; z-index: 9998; background: rgba(0,0,0,.88); display: none; }
.deck-view[data-zoom] .deck-backdrop { display: block; }
.deck-view[data-zoom] .deck-zoom { opacity: 1; }
/* Copy-to-clipboard feedback. Slidev's own .slidev-code-copy buttons survive the capture —
32 of them, wrappers and all — but their click handler was Vue and did not. Reviving them
is ten lines; rebuilding that DOM from the markdown would have been hundreds. */
.deck-copied::after {
content: "Copiado";
position: absolute; top: .4rem; right: 2.4rem; z-index: 3;
font: 600 .7rem/1 ui-sans-serif, system-ui, sans-serif;
padding: .25rem .45rem; border-radius: .3rem;
background: #E8A838; color: #1a1a1a;
pointer-events: none;
}
.deck-copied .slidev-code-copy { opacity: 1 !important; }
/* The button ships in the captured DOM but measures 0x0: UnoCSS's preflight sets
button{padding:0} and the icon carries no intrinsic size, so its hit area is nothing.
It was there, and no human could ever click it. Give it a surface. */
.deck-view .slidev-code-copy {
width: 1.9rem; height: 1.9rem; padding: 0;
top: .4rem; right: .4rem;
display: grid; place-content: center;
border-radius: .3rem; cursor: pointer;
background: rgba(255,255,255,.08); color: #dbd7ca;
opacity: 0; transition: opacity .15s, background .15s;
}
.deck-view .slidev-code-copy svg { width: 1rem; height: 1rem; }
.deck-view .slidev-code-wrapper:hover .slidev-code-copy,
.deck-view .slidev-code-copy:focus-visible { opacity: 1; }
.deck-view .slidev-code-copy:hover { background: rgba(255,255,255,.18); }
/* Fit on BOTH axes: 96vw alone overflows vertically on a wide window, and the point of
zoom is to see the whole slide, not a cropped one. */
/* Top-aligned, not centred: with variable heights a centred modal drifts below the fold on
the tall slides — exactly the ones you zoom in to read. And the width is bounded by the
slide's OWN height (--deck-h), not by a 16:9 assumption that 12 of 16 slides break. */
.deck-view[data-zoom] .deck-slide[data-active] {
position: fixed; z-index: 9999; top: 1rem; left: 50%;
translate: -50% 0;
width: min(96vw, calc((100vh - 2rem) * ${W} / var(--deck-h, ${H})));
border-color: rgba(255,255,255,.2);
box-shadow: 0 24px 80px rgba(0,0,0,.6);
}
/* On a portrait phone the slide's WIDTH is already exhausted — fitting 16:9 into 390px
leaves it 328px and the zoom buys 1.15x, i.e. nothing. The only free dimension is
height. Rotate, as a photo lightbox does with a landscape shot: measured 2.03x. */
@media (orientation: portrait) and (max-width: 48rem) {
.deck-view[data-zoom] .deck-slide[data-active] {
top: 50%; translate: -50% -50%;
width: min(94vh, calc(96vw * ${W} / var(--deck-h, ${H})));
rotate: 90deg;
}
}
.deck-view:focus { outline: none; }
.deck-view:focus-visible { outline: 2px solid #E8A838; outline-offset: 4px; border-radius: .5rem; }
@media print {
.deck-bar, .deck-toc, .deck-zoom { display: none; }
.deck-view[data-mode="slider"] .deck-slide { display: block; }
.deck-slide { break-inside: avoid; page-break-inside: avoid; box-shadow: none; }
}`
const js = (W, H) => `
(function () {
var deck = document.getElementById('deck-view');
if (!deck) return;
var W = ${W}, H = ${H};
// Strings come from the markup, not from the script: one deck.js serves every language and
// stays cacheable, while the chrome around the deck speaks the page's language.
var T_SLIDE = deck.dataset.tSlide || 'Slide';
var T_ZOOM = deck.dataset.tZoom || 'Zoom';
var n = +deck.dataset.count;
var slides = [].slice.call(deck.querySelectorAll('.deck-slide'));
var count = deck.querySelector('.deck-count');
var navs = deck.querySelector('.deck-navs');
var list = deck.querySelector('.deck-toc-list');
var i = 0;
// The index is DERIVED from each slide's own H1, never authored — a hand-written TOC
// is one more surface to drift out of sync with the deck, which is the exact disease
// this projection exists to cure.
slides.forEach(function (s, k) {
s.setAttribute('data-slide-label', (k + 1) + ' / ' + n);
s.id = 'slide-' + (k + 1);
var h = s.querySelector('h1, h2, h3');
var title = (h && h.textContent.trim()) || (T_SLIDE + ' ' + (k + 1));
var z = document.createElement('button');
z.type = 'button'; z.className = 'deck-zoom'; z.dataset.zoomSlide = k;
z.setAttribute('aria-label', T_ZOOM + ' ' + (k + 1));
z.textContent = '\\u2921';
s.appendChild(z);
var li = document.createElement('li');
var b = document.createElement('button');
b.type = 'button'; b.className = 'deck-toc-link'; b.dataset.goto = k;
b.innerHTML = '<span class="deck-toc-num"></span><span class="deck-toc-text"></span>';
b.querySelector('.deck-toc-num').textContent = (k + 1) + '.';
b.querySelector('.deck-toc-text').textContent = title;
li.appendChild(b); list.appendChild(li);
});
var links = [].slice.call(list.querySelectorAll('.deck-toc-link'));
// Each slide's REAL content height. Slidev's frame is 552px tall and 12 of this deck's 16
// slides hold more than that — up to 672px — so a rigid frame silently cuts three quarters
// of the deck. Slidev clips them too; nobody noticed, because until now the slides were
// PNGs of themselves. A reading surface may not lose content.
function contentHeight(slide) {
var page = slide.querySelector('.slidev-page') || slide.querySelector('.print-slide-container');
if (!page) return H;
// The container is already scaled by --deck-k, and getBoundingClientRect reports
// POST-transform coordinates. Divide it back out, or --deck-h lands in scaled pixels and
// the CSS scales it a second time.
var k = parseFloat(getComputedStyle(slide).getPropertyValue('--deck-k')) || 1;
var top = page.getBoundingClientRect().top;
var max = H, all = page.querySelectorAll('*');
for (var i = 0; i < all.length; i++) {
var bottom = (all[i].getBoundingClientRect().bottom - top) / k;
if (bottom > max) max = bottom;
}
return Math.ceil(max);
}
// Measure in whatever mode we are in. A display:none slide has no geometry, so force the
// stacked layout, measure, and restore — synchronously, so the browser never paints the
// intermediate state. Doing this by actually SWITCHING mode created a race: the markup
// ships stacked (the right no-JS fallback), the switch to slider waited on fonts, and a
// reader who pressed "Read all" inside that window was silently thrown back to slider.
function measureAll() {
var prev = deck.dataset.mode;
deck.dataset.mode = 'read';
slides.forEach(function (s) { s.style.setProperty('--deck-h', contentHeight(s)); });
deck.dataset.mode = prev;
}
// The scale CSS cannot express: box width / native width, as a plain number.
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
// A display:none slide reports 0x0. Believing it writes --deck-k: 0 onto the fifteen
// hidden slides, and when the reader switches back to stacked their cards are zero-height
// until the observer fires again — asynchronously, so anything that measures in between
// sees an empty deck. An observer must not trust a measurement of an element that is not
// in the layout: keep the last good scale.
if (e.contentRect.width > 0) e.target.style.setProperty('--deck-k', e.contentRect.width / W);
});
});
slides.forEach(function (s) { ro.observe(s); });
deck.setAttribute('data-js', '');
// In slider mode the current slide is the one shown. Stacked, it is the one you are
// LOOKING at — which is not the same as the one you last clicked, so the rail follows
// the scroll rather than the click.
var visible = 0;
function mark() {
var cur = deck.dataset.mode === 'slider' ? i : visible;
links.forEach(function (l, k) { l.setAttribute('aria-current', String(k === cur)); });
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) {
var k = slides.indexOf(e.target);
if (k >= 0) { visible = k; if (deck.dataset.mode === 'read') mark(); }
}
});
}, { rootMargin: '-45% 0px -45% 0px' }); // whichever slide crosses the viewport's middle
slides.forEach(function (s) { io.observe(s); });
function show(k) {
i = (k + n) % n;
slides.forEach(function (s, j) { s.toggleAttribute('data-active', j === i); });
count.textContent = (i + 1) + ' / ' + n;
mark();
}
function setMode(m) {
deck.dataset.mode = m;
deck.querySelectorAll('[data-set-mode]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.dataset.setMode === m));
});
var slider = m === 'slider';
count.hidden = !slider; navs.hidden = !slider;
// Stacked, the rail is the navigation — a collapsed <details> above 16 slides is off
// screen by slide 5. Open it. In slider mode leave it as the reader left it.
if (slider) show(i); else mark();
}
function goto(k) {
if (deck.dataset.mode === 'slider') { show(k); deck.focus(); }
else { i = k; slides[k].scrollIntoView({ behavior: 'smooth', block: 'start' }); }
}
// The rail shows by default where there is width for a column; narrow, it starts hidden
// so it does not push the deck below the fold. Either way the reader can toggle it.
var wide = window.matchMedia('(min-width: 60rem)');
var toggle = deck.querySelector('.deck-toc-toggle');
function setToc(on) {
deck.dataset.toc = on ? 'on' : 'off';
toggle.setAttribute('aria-expanded', String(on));
}
setToc(wide.matches);
toggle.addEventListener('click', function () { setToc(deck.dataset.toc !== 'on'); });
// The sticky bar has to sit on the page's own background, whatever it is. Measure it
// rather than guess it, and re-measure when the SITE switches theme — it has its own
// toggle, and a bar frozen to the old palette is worse than no bar.
function pageBg() {
var e = deck.parentElement;
while (e) {
var bg = getComputedStyle(e).backgroundColor;
// No regex here on purpose. Inside a template literal every backslash is eaten once
// before the regex engine sees it, and /^rgba\(0,0,0,0\)$/ silently became a capture
// group that matched nothing — syntactically valid, semantically wrong, invisible to
// a syntax check. String comparison needs no escapes and cannot be mis-escaped.
if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') return bg;
e = e.parentElement;
}
return getComputedStyle(document.body).backgroundColor;
}
function syncPageBg() { deck.style.setProperty('--deck-page-bg', pageBg()); }
syncPageBg();
new MutationObserver(syncPageBg).observe(document.documentElement, {
attributes: true, attributeFilter: ['class', 'data-theme'],
});
var lastFocus = null;
function zoomOpen(k) {
lastFocus = document.activeElement;
show(k);
deck.setAttribute('data-zoom', '');
document.body.style.overflow = 'hidden';
deck.focus();
}
function zoomClose() {
if (!deck.hasAttribute('data-zoom')) return;
deck.removeAttribute('data-zoom');
document.body.style.overflow = '';
if (lastFocus && lastFocus.isConnected) lastFocus.focus();
}
// Slidev's copy buttons are in the captured DOM but inert — their handler was Vue.
// Rewire them. innerText, not textContent: it is the RENDERED code, so Shiki's per-line
// spans come back as actual newlines instead of one run-on string.
function copyCode(btn) {
var wrap = btn.closest('.slidev-code-wrapper');
var pre = wrap && wrap.querySelector('pre');
if (!pre) return;
var text = pre.innerText.replace(/\\n$/, '');
var done = function () {
wrap.classList.add('deck-copied');
setTimeout(function () { wrap.classList.remove('deck-copied'); }, 1200);
};
function legacy() {
var ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } finally { ta.remove(); }
}
// The Clipboard API REJECTS when the document is not focused — a real case, not a
// theoretical one. Without the catch, the copy fails in complete silence.
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done, legacy);
} else {
legacy();
}
}
deck.addEventListener('click', function (e) {
var cp = e.target.closest('.slidev-code-copy');
if (cp) { e.preventDefault(); return copyCode(cp); }
if (e.target.closest('.deck-backdrop')) return zoomClose();
// data-zoom-slide (the button) is deliberately NOT data-zoom (the deck's open state).
var z = e.target.closest('[data-zoom-slide]');
if (z) return deck.hasAttribute('data-zoom') ? zoomClose() : zoomOpen(+z.dataset.zoomSlide);
var m = e.target.closest('[data-set-mode]');
if (m) return setMode(m.dataset.setMode);
var t = e.target.closest('[data-goto]');
if (t) return goto(+t.dataset.goto);
var nav = e.target.closest('.deck-nav');
if (nav) show(i + (+nav.dataset.dir));
});
// Keys are scoped to the deck holding focus. A document-wide Space handler would steal
// page scrolling from every reader who never asked the deck for anything.
deck.addEventListener('keydown', function (e) {
var zoomed = deck.hasAttribute('data-zoom');
if (zoomed && e.key === 'Escape') { e.preventDefault(); return zoomClose(); }
// Zoomed, the keys must drive even stacked: a lightbox you can leave but not walk is
// a dead end.
if (!zoomed && deck.dataset.mode !== 'slider') return;
if (e.target.closest('.deck-toc')) return;
var fwd = e.key === 'ArrowRight' || e.key === 'ArrowDown' || (e.key === ' ' && !e.shiftKey);
var back = e.key === 'ArrowLeft' || e.key === 'ArrowUp' || (e.key === ' ' && e.shiftKey);
if (fwd) { e.preventDefault(); show(i + 1); }
else if (back) { e.preventDefault(); show(i - 1); }
else if (e.key === 'Home') { e.preventDefault(); show(0); }
else if (e.key === 'End') { e.preventDefault(); show(n - 1); }
});
// Printing in slider mode would emit exactly one slide.
window.addEventListener('beforeprint', function () { setMode('read'); });
// Measure AFTER the webfonts land. With the fallback font the text is shorter and every
// slide "fits"; once Nunito Sans arrives it grows and spills. Measured before fonts, every
// --deck-h came back 552 and nine slides were still cut.
// And measure while still stacked: a display:none slide has no geometry at all. So the
// switch to slider waits for the measurement, not the other way round.
var hash = (location.hash.match(/^#slide-(\\d+)$/) || [])[1];
// Take the mode immediately — no waiting, no race with the reader.
measureAll();
setMode('slider');
if (hash) show(+hash - 1);
// Then measure again once the webfonts land: with the fallback face the text is shorter
// and every slide "fits", so a pre-font measurement returns 552 for all of them and nine
// slides stay cut. This pass changes no mode and interrupts nothing.
if (document.fonts && document.fonts.ready) document.fonts.ready.then(measureAll);
window.addEventListener('resize', measureAll);
})();`