777 lines
43 KiB
JavaScript
777 lines
43 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.
|
||
|
||
// 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}">←</button>
|
||
<button type="button" class="deck-nav" data-dir="1" aria-label="${t.next}">→</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);
|
||
})();`
|