// 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 . 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(/]*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 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 ? `` : '' return `
${named}` }) // 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('
('
').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 }) => `
1 / ${n}
${body}
` 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
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 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
, 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 , 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