ontoref-outreach/scripts/decks/lib/build-body.mjs
2026-07-17 01:00:32 +01:00

777 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.
// EL ANCLA SE EMITE EN EL HTML; NO SE ASIGNA EN EL SCRIPT.
//
// `s.id = 'slide-' + (k+1)` vivía en deck.js, y por eso ninguna slide era direccionable: el
// navegador resuelve el hash de la URL ANTES de que el script corra, así que `#slide-7` no
// encontraba nada — y en modo slider la slide destino está en `display:none`, o sea que ni
// scroll había que hacer. Un id que aparece después de que el navegador lo busque no es un id:
// es un adorno. Los cuatro términos del glosario que enlazan a este deck aterrizaban todos en
// la portada por esto, no porque a nadie se le ocurriera escribir el fragmento.
//
// En el marcado, el ancla existe para el hash, para el rastreador y para quien no tiene JS.
export function buildBody(deckHtml, { id, count, W, H, t, anchors = [] }) {
// Las anclas se leen de la FUENTE y las slides se cuentan en la CAPTURA. Si los dos números
// no coinciden, la lista está desalineada y cada ancla nombra a la slide de al lado — en
// silencio, que es el único fallo de este fichero que un lector no podría ver.
if (anchors.length && anchors.length !== count) {
throw new Error(`la fuente declara ${anchors.length} slides y la captura contó ${count}: las anclas quedarían pegadas a la slide equivocada`)
}
let n = 0
let body = deckHtml.replace(/<div([^>]*class="print-slide-container"[^>]*)>/g, (_, attrs) => {
const i = n++
// Dos direcciones para una tarjeta, y hacen falta las dos:
// · `slide-N` — TODA slide es direccionable sin anotar nada. Es posicional a propósito:
// sirve para «cópiame la URL de lo que estoy viendo», donde el número ES lo que se mira.
// · el ancla nombrada — la que se escribe en el léxico, porque sobrevive a un reordenado.
// Un id por elemento, así que la nombrada va en un <a> vacío DENTRO de la figure: el hash
// resuelve al mismo sitio y el JS sube al .deck-slide que la contiene.
const a = anchors[i]
const named = a ? `<a class="deck-anchor" id="${a}" aria-hidden="true"></a>` : ''
return `<figure class="deck-slide" id="slide-${i + 1}" data-slide="${i}"${i === 0 ? ' data-active' : ''}>${named}<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, anchors }
}
// 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>
<button type="button" class="deck-help-toggle" aria-expanded="false" aria-controls="deck-help"
aria-label="${t.help}" title="${t.help}">?</button>
</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>
<aside class="deck-help" id="deck-help" hidden aria-label="${t.help_title}">
<button type="button" class="deck-help-close" aria-label="×">×</button>
<p class="deck-help-title">${t.help_title}</p>
<ul class="deck-help-list">${t.help_items.map(it => {
const i = it.indexOf(' — ')
const label = i > 0 ? it.slice(0, i) : ''
const desc = i > 0 ? it.slice(i + 3) : it
return `<li>${label ? `<strong>${label}</strong> — ` : ''}${desc}</li>`
}).join('')}</ul>
</aside>
<div class="deck-read dark"><div class="deck-slides">${body}</div></div>
<div class="deck-backdrop"></div>
</div>`
const css = (W, H) => `
/* The deck CLAIMS its width. It has no intrinsic one — the slides are width:100% and their
content is position:absolute, so the deck contributes ZERO to its parent's width calculation.
And the site's <main> is a flex item that shrinks to fit its content, so the page is as wide as
the longest paragraph on it: a deck page whose excerpt is empty collapsed to 728px and rendered
its slides at 400px, while the one with a long excerpt got 1216px. A deck that is as wide as the
prose beside it happens to be is not a deck. Declare the floor; do not inherit it. */
.deck-view { min-width: min(76rem, calc(100vw - 6rem)); }
.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); }
/* El ancla nombrada es una DIRECCIÓN, no un elemento: cero tamaño y fuera del flujo, o un <a>
vacío en línea abre un hueco de una línea en la cabecera de la slide. La .deck-slide ya es
position:relative (por el botón de zoom), así que absolute la ancla al borde de la tarjeta. */
.deck-anchor { position: absolute; top: 0; left: 0; width: 0; height: 0; overflow: hidden; }
/* LA BARRA PEGAJOSA SE COME LA CABECERA DE LA SLIDE A LA QUE ACABAS DE SALTAR.
Aterrizar «arriba del todo» de la tarjeta la deja DEBAJO de la barra: el título cortado por la
mitad, que es exactamente donde el enlace prometía dejarte. El margen va también en el .deck-ancla
porque el scroll NATIVO (sin JS, o antes de que arranque) apunta al ancla, no a la figure —
ponerlo solo en .deck-slide no arregla el único caso en que el navegador scrollea solo.
4rem es el suelo; con JS se mide la barra de verdad y se pisa este valor. */
.deck-slide, .deck-anchor { scroll-margin-top: 4rem; }
/* 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; }
/* Help — a "how to use" toggle at the far right, opening a panel that explains the controls. */
/* Icon-only "?" — the universal help affordance, rightmost and set apart from the nav arrows
(margin-left gap) so it reads as meta, not navigation. The label lives in aria-label + title. */
.deck-help-toggle {
margin-left: 1rem; flex: none;
width: 1.9rem; height: 1.9rem; padding: 0;
display: grid; place-content: center;
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
border-radius: 50%; background: transparent;
cursor: pointer; font: 600 1rem/1 inherit; color: inherit;
}
.deck-help-toggle:hover { background: color-mix(in srgb, currentColor 10%, transparent); }
.deck-help-toggle[aria-expanded="true"] { background: #E8A838; color: #1a1a1a; border-color: #E8A838; }
.deck-help {
position: relative; margin: 0 0 1rem; padding: 1rem 1.25rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-left: 3px solid #E8A838; border-radius: .4rem;
background: color-mix(in srgb, currentColor 5%, transparent);
font-size: .85rem; line-height: 1.5;
}
.deck-help[hidden] { display: none; }
.deck-help-title { margin: 0 0 .6rem; font-weight: 600; }
.deck-help-list { margin: 0; padding-left: 1.1rem; display: grid; gap: .5rem; }
.deck-help-list strong { color: #E8A838; }
/* Key-cap styling, like daisyUI's kbd. Scoped to .deck-view so the site's .prose kbd cannot
override it. currentColor-based so it works in the site's light and dark themes. */
.deck-view .deck-help kbd {
display: inline-block; min-width: 1.5em; padding: .05em .45em;
border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
border-bottom-width: 2px; border-radius: .35em;
background: color-mix(in srgb, currentColor 8%, transparent);
font: 600 .82em/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
text-align: center; vertical-align: baseline;
}
.deck-help-close {
position: absolute; top: .4rem; right: .5rem;
border: 0; background: transparent; color: inherit; opacity: .6;
font-size: 1.3rem; line-height: 1; cursor: pointer;
}
.deck-help-close:hover { opacity: 1; }
.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" "help help" "toc slides";
column-gap: 1.5rem;
}
.deck-view[data-toc="off"] {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "bar" "help" "slides";
}
.deck-view .deck-bar { grid-area: bar; }
.deck-view .deck-read { grid-area: slides; }
/* Own full-width row below the bar. Hidden, [hidden]→display:none collapses the row to 0. */
.deck-view .deck-help { grid-area: help; }
.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. */
/* Compensated for the slide's scale, exactly like the glossary marker — and for exactly the
same reason, which I had already learned once and failed to generalise: this button is a UI
affordance drawn INSIDE the slide, and the slide is transform-scaled afterwards. On a deck the
site rendered at k=0.41, a 1.9rem button reached the screen at 12x12px: present, and impossible
to click. Gate K caught it on the second deck. Anything the READER must hit has to keep a
constant size on screen, whatever the slide's scale. */
.deck-view .slidev-code-copy {
width: calc(1.9rem / max(var(--deck-k, 1), 0.35));
height: calc(1.9rem / max(var(--deck-k, 1), 0.35));
padding: 0;
top: .4rem; right: .4rem;
display: grid; place-content: center;
border-radius: .3rem; cursor: pointer;
/* Discoverable, not opacity:0. On a dark code block, rgba(255,255,255,.08) with no border was a
dark square on dark — invisible. Give it a lighter fill, a visible border and near-full
opacity so it reads as a button; brighten on hover. */
background: rgba(255,255,255,.14);
border: 1px solid rgba(255,255,255,.25) !important;
color: #f0ece0;
opacity: .75; transition: opacity .15s, background .15s;
}
.deck-view .slidev-code-wrapper:hover .slidev-code-copy,
.deck-view .slidev-code-copy:hover { opacity: 1; background: rgba(255,255,255,.28); }
.deck-view .slidev-code-copy svg {
width: calc(1rem / max(var(--deck-k, 1), 0.35));
height: calc(1rem / max(var(--deck-k, 1), 0.35));
}
.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);
// El id ya viene EN EL MARCADO (build-body). Asignarlo aquí es asignarlo tarde: el
// navegador ya resolvió el hash y no encontró nada.
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('*');
// An image reaching past 1.5x the frame is a decorative BLEED, not content: Slidev renders it
// oversized and clips it at the 552 frame ON PURPOSE (rustikon 17/23: a Ferris at ~1170px).
// Growing the card to contain it turns the slide into a giant portrait. A small image that
// overflows alongside the text (ontology's svg at ~660px) is NOT a bleed — it grows with the
// text. So: a bleeding image pins the slide to the native frame, and the card's overflow:hidden
// clips it exactly where Slidev does. Only TEXT overflow earns a taller card.
var BLEED = H * 1.5;
for (var i = 0; i < all.length; i++) {
var el = all[i];
var bottom = (el.getBoundingClientRect().bottom - top) / k;
if (bottom > max) max = bottom;
if (/^(img|svg|picture)$/i.test(el.tagName) && bottom > BLEED) return H;
}
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' }); }
// Mobile only (< 60rem, where the index and the slides cannot sit side by side): after a jump,
// close the index so the target slide is not left buried beneath it. On desktop the rail is a
// permanent sidebar — leave it open.
if (!window.matchMedia('(min-width: 60rem)').matches) setToc(false);
}
// 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'],
});
// Help panel: a plain show/hide, and its own close button. Its content is server-rendered per
// language (from decks.ncl help_items); JS only toggles visibility.
var help = deck.querySelector('.deck-help');
var helpBtn = deck.querySelector('.deck-help-toggle');
function setHelp(on) {
help.hidden = !on;
helpBtn.setAttribute('aria-expanded', String(on));
}
helpBtn.addEventListener('click', function () { setHelp(help.hidden); });
deck.querySelector('.deck-help-close').addEventListener('click', function () { setHelp(false); });
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'); });
// ── DÓNDE ATERRIZA UN ENLACE ────────────────────────────────────────────────────────
//
// Dos formas, y el lector no tiene por qué saber cuál le tocó:
// #slide-7 posicional — toda slide la tiene, es la URL de «lo que estoy viendo».
// #ncl nombrada — declarada en el frontmatter de la slide; es la que se escribe
// en el léxico, porque sobrevive a que el deck se reordene.
// La nombrada se resuelve por el DOM (getElementById → la .deck-slide que la contiene) en
// vez de por una tabla en este script: el ancla ya está en el marcado, y una segunda copia
// aquí es una segunda cosa que se puede desincronizar de la primera.
//
// El navegador NO puede hacer este salto solo. En modo slider la slide destino está en
// display:none y un ancla dentro de ella no tiene geometría: el scroll nativo no va a
// ninguna parte. Por eso el destino se ACTIVA antes de mirarlo, y por eso el id en el
// marcado no basta por sí solo.
function slideFromHash() {
var h = location.hash;
if (!h || h === '#') return -1;
var num = h.match(/^#slide-(\\d+)$/);
if (num) {
var k = +num[1] - 1;
return (k >= 0 && k < n) ? k : -1;
}
// Un id cualquiera del deck: puede ser el ancla nombrada o algo dentro de una slide.
var el = null;
try { el = document.getElementById(decodeURIComponent(h.slice(1))); } catch (e) { el = null; }
var fig = el && el.closest ? el.closest('.deck-slide') : null;
return fig ? slides.indexOf(fig) : -1;
}
// LA BARRA SE MIDE; NO SE ADIVINA. Es sticky en top:0, así que tapa la cabecera de la slide a
// la que acabas de saltar — el título cortado justo debajo. El CSS deja 4rem de suelo, pero la
// barra crece con el tipo de letra del sitio y con el ancho (los botones se envuelven), y un
// número escrito a mano está bien el día que se escribe. scroll-margin es lo único que
// scrollIntoView respeta, así que la medida va ahí.
function syncScrollMargin() {
var bar = deck.querySelector('.deck-bar');
var g = (bar ? Math.ceil(bar.getBoundingClientRect().height) : 48) + 12;
slides.forEach(function (s) { s.style.scrollMarginTop = g + 'px'; });
}
syncScrollMargin();
window.addEventListener('resize', syncScrollMargin);
function gotoHash(smooth) {
var k = slideFromHash();
if (k < 0) return false;
// No es goto(k): aquí no queremos cerrar el índice ni robar el foco de la página a la
// que el lector acaba de llegar. Solo poner delante la slide que pidió la URL.
if (deck.dataset.mode === 'slider') show(k);
else i = k;
// Y SE SCROLLEA EN LOS DOS MODOS. En slider esto faltaba: show(k) solo activa la slide, así
// que lo único que movía la página era el scroll NATIVO del navegador al ancla — hecho antes
// de que este script corra y con el deck todavía apilado. Al cambiar a slider el documento
// encoge de 16 tarjetas a una, ese scroll queda apuntando a un layout que ya no existe, y el
// lector aterriza con el título medio tapado por la barra. La slide correcta, mal puesta.
slides[k].scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'start' });
return true;
}
// 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.
// Take the mode immediately — no waiting, no race with the reader.
measureAll();
setMode('slider');
gotoHash(false);
// Un enlace al MISMO deck con otro fragmento no recarga la página: sin esto, pulsar NCL y
// luego DAG desde otro tooltip cambia la URL y deja la slide donde estaba. La URL diría una
// cosa y la pantalla otra — que es la deriva de siempre, servida a 200.
window.addEventListener('hashchange', function () { gotoHash(true); });
// 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);
})();`