decks: the slides stop being pixels — Slidev's own render, captured and re-homed

The published deck was a hand-written carousel of exported PNGs: the words of every
slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could reach
them, no screen reader read them, and the page drifted from the source the moment a
slide changed.

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

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

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

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

Twelve gates (A–L), and IN THE CHAIN: `just decks` in sync-full, `decks-check` in
`check` (on disk), `decks-served` in `check-live` (the page). A generator outside the
chain is a generator that does not run.
This commit is contained in:
Jesús Pérez 2026-07-14 19:41:51 +01:00
parent 1b6bae50db
commit da93544de3
28 changed files with 4239 additions and 157 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.coder/
.claude/
.internal.git/
presentation/node_modules/
presentation/dist/
presentation/dist-prerender/
*.swp

26
presentation/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "rustikon-2026-slides",
"version": "1.0.0",
"description": "Why I Needed Rust, Finally - Rustikon 2026",
"scripts": {
"dev": "slidev --port 4040",
"build": "slidev build",
"prerender": "slidev-prerender",
"export": "slidev export",
"export:pdf": "slidev export --format pdf",
"export:png": "slidev export --format png"
},
"devDependencies": {
"@slidev/cli": "^52.2.5",
"@slidev/theme-apple-basic": "^0.25.1",
"@slidev/theme-default": "^0.25.0",
"@slidev/theme-seriph": "^0.25.0",
"@vueuse/core": "^13.9.0",
"@vueuse/shared": "14.0.0-alpha.0",
"playwright-chromium": "^1.61.1",
"postcss": "^8.5.19"
},
"dependencies": {
"slidev-prerender": "0.0.1-beta.0"
}
}

45
scripts/decks/decks.ncl Normal file
View file

@ -0,0 +1,45 @@
# Which Slidev deck projects onto which site pages.
#
# One source, N pages: the deck is not translated, so a single capture feeds both the
# es and en surfaces — they differ only in slug and frontmatter, never in body.
{
decks = [
{
source = "ontology_slides.md", # relative to outreach/presentation/
id = "ontoref-ontologia-reflexion",
pages = [
{ lang = "es", slug = "ontoref-ontologia-reflexion" },
{ lang = "en", slug = "ontoref-ontology-and-reflection" },
],
},
],
# Classes the site is entitled to carry INTO the deck. Everything else of the site's
# is reverted at the boundary. `gloss-`: the glossary decorates deck text — the whole
# point of the projection. `deck-`: the projection's own chrome (card, switcher, zoom).
boundary_allow = ["[class*=\"gloss-\"]", "[class*=\"deck-\"]"],
# Slidev's native slide geometry. The WIDTH is the scaling base; the height is only a
# floor — 12 of 16 slides in this deck hold more than 552px, so the card grows to its
# measured content instead of cutting it.
slide = { width = 980, height = 552 },
# The chrome is the SITE's UI, so it speaks the page's language. One capture, N pages:
# the deck body is shared, the wrapper around it is not.
i18n = {
es = {
read = "Leer todo", slider = "Diapositivas", index = "Índice",
index_head = "Índice", slides = "diapositivas",
prev = "Anterior", next = "Siguiente",
zoom = "Ampliar diapositiva", slide = "Diapositiva",
aria = "Deck de %n diapositivas. Con el foco puesto, las flechas o la barra espaciadora navegan.",
},
en = {
read = "Read all", slider = "Slides", index = "Index",
index_head = "Index", slides = "slides",
prev = "Previous", next = "Next",
zoom = "Enlarge slide", slide = "Slide",
aria = "Deck of %n slides. With focus on it, arrows or the space bar navigate.",
},
},
}

View file

@ -0,0 +1,130 @@
// Gates over the CSS artifact itself, on a deliberately hostile synthetic page.
// No server needed — these are properties of the bundle, not of the deployment.
//
// A CONTAINMENT every computed style OUTSIDE the wrapper is unchanged by the bundle
// B FIDELITY styles INSIDE it DO change (a bundle that styles nothing passes A)
// C NO INBOUND LEAK the site's element rules do not reach inside
// D GLOSSARY .gloss-term still decorates deck text — the point of the projection
//
// Usage: node gates/artifact.mjs <id>
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { chromium, publicDir } from '../lib/paths.mjs'
const id = process.argv[2]
if (!id) { console.error('uso: node gates/artifact.mjs <deck-id>'); process.exit(2) }
const bundle = await readFile(join(publicDir(id), 'deck.css'), 'utf8')
// Bare element selectors with the kind of declarations a real content site ships. If any
// of these reach inside the deck, C fails.
const SITE_CSS = `
body { margin: 2rem; font-family: Georgia, serif; font-size: 18px; line-height: 1.6; color: #222; background: #fff; }
h1 { font-size: 2rem; font-weight: 700; } h2 { font-size: 1.5rem; }
p { margin: 1rem 0; } ul { list-style: disc; padding-left: 2rem; }
code { font-family: monospace; background: #eee; padding: 2px 4px; border-radius: 3px; }
pre { background: #eee; padding: 1rem; }
a { color: #0645ad; text-decoration: underline; }
td, th { border: 1px solid #999; padding: .4rem; }
`
// Exactly as glossary-tooltip.js injects it.
const TOOLTIP_CSS = '.gloss-term{background:none;border:0;padding:0;font:inherit;color:inherit;'
+ 'cursor:help;border-bottom:1px dotted currentColor;opacity:.95}'
const DECK = `<div class="deck-read dark" data-js>
<figure class="deck-slide" data-active><div class="print-slide-container">
<div class="slidev-page"><div class="slidev-layout default">
<h1>Título de la slide</h1>
<p>Texto con <code>NCL</code> y <button class="gloss-term" id="in">ontología</button>.</p>
<ul><li>Punto</li></ul><pre><code>let x = 1</code></pre>
<table><tr><td>celda</td></tr></table>
</div></div>
</div></figure>
</div>`
const page = (withDeck) => `<!doctype html><html lang="es"><head><meta charset="utf-8">
<style>${SITE_CSS}</style>${withDeck ? `<style>${bundle}</style>` : ''}
</head><body>
<h1 id="p-h1">Título del sitio</h1>
<p id="p-p">Texto con <code id="p-code">NCL</code> y <a href="#" id="p-a">enlace</a>.</p>
<ul id="p-ul"><li id="p-li">Punto</li></ul>
${DECK}
<p id="p-after">Después del deck.</p>
<button class="gloss-term" id="out">fuera</button>
<script>var s=document.createElement('style');s.textContent=${JSON.stringify(TOOLTIP_CSS)};document.head.appendChild(s);</script>
</body></html>`
const PROBES = ['p-h1', 'p-p', 'p-code', 'p-a', 'p-ul', 'p-li', 'p-after']
const PROPS = ['font-family', 'font-size', 'font-weight', 'line-height', 'color', 'background-color',
'margin-top', 'margin-bottom', 'padding-left', 'display', 'list-style-type',
'text-decoration-line', 'box-sizing', 'border-top-width']
const snap = (p) => p.evaluate(({ PROBES, PROPS }) => {
const out = {}
const grab = (el, key) => {
if (!el) return
const cs = getComputedStyle(el)
out[key] = Object.fromEntries(PROPS.map(x => [x, cs.getPropertyValue(x)]))
}
PROBES.forEach(id => grab(document.getElementById(id), id))
grab(document.body, 'body')
grab(document.documentElement, 'html')
const d = document.querySelector('.deck-read')
grab(d?.querySelector('h1'), 'DECK:h1')
grab(d?.querySelector('p'), 'DECK:p')
grab(d?.querySelector('code'), 'DECK:code')
grab(d?.querySelector('pre'), 'DECK:pre')
grab(d?.querySelector('li'), 'DECK:li')
grab(d?.querySelector('td'), 'DECK:td')
return out
}, { PROBES, PROPS })
const browser = await chromium.launch()
const p = await browser.newPage({ viewport: { width: 1200, height: 900 } })
await p.setContent(page(false), { waitUntil: 'load' })
const before = await snap(p)
await p.setContent(page(true), { waitUntil: 'load' })
await p.waitForTimeout(500)
const after = await snap(p)
const outside = [], inside = []
for (const k of Object.keys(before)) {
for (const prop of PROPS) {
if (before[k]?.[prop] !== after[k]?.[prop]) {
;(k.startsWith('DECK:') ? inside : outside).push({ el: k, prop, de: before[k]?.[prop], a: after[k]?.[prop] })
}
}
}
// Fingerprints of the site's own rules that must NOT survive inside the deck.
const LEAKS = [
{ el: 'DECK:code', prop: 'background-color', forbidden: 'rgb(238, 238, 238)', from: 'code{background:#eee}' },
{ el: 'DECK:pre', prop: 'background-color', forbidden: 'rgb(238, 238, 238)', from: 'pre{background:#eee}' },
{ el: 'DECK:li', prop: 'list-style-type', forbidden: 'disc', from: 'ul{list-style:disc}' },
{ el: 'DECK:p', prop: 'font-family', forbidden: 'Georgia, serif', from: 'body{font-family:Georgia}' },
{ el: 'DECK:td', prop: 'border-top-width', forbidden: '1px', from: 'td{border:1px}' },
].filter(f => after[f.el] && after[f.el][f.prop] === f.forbidden)
const gloss = await p.evaluate(() => {
const g = id => { const cs = getComputedStyle(document.getElementById(id))
return cs.borderBottomStyle + '/' + cs.borderBottomWidth + '/' + cs.cursor }
return { dentro: g('in'), fuera: g('out') }
})
await browser.close()
const A = outside.length === 0
const B = inside.length > 0
const C = LEAKS.length === 0
const D = gloss.dentro === gloss.fuera && gloss.dentro.startsWith('dotted')
const ok = A && B && C && D
console.log(JSON.stringify({
A_contencion: A ? 'PASS — 0 cambios fuera del wrapper' : `FAIL — ${outside.length} cambios fuera`,
B_fidelidad: B ? `PASS — ${inside.length} props estiladas dentro` : 'FAIL — el deck no está estilado',
C_sin_fuga_de_entrada: C ? 'PASS — ninguna regla del sitio alcanza el interior' : `FAIL — ${LEAKS.length} fugas`,
D_glosario: D ? `PASS — decora dentro igual que fuera (${gloss.dentro})` : `FAIL — dentro ${gloss.dentro}, fuera ${gloss.fuera}`,
...(A ? {} : { cambiosFuera: outside.slice(0, 10) }),
...(C ? {} : { fugas: LEAKS }),
}, null, 2))
process.exit(ok ? 0 : 1)

View file

@ -0,0 +1,296 @@
// Gates over the PAGE AS SERVED. Verifying the file would prove nothing: the site's
// content pipeline sits between the generator and the reader.
//
// E SLIDES every slide RENDERS (a DOM-node count passes while overflow:hidden
// silently swallows 14 of 16), and stacked mode reaches them all
// F FONTS zero third-party requests, zero 4xx, the self-hosted faces load
// H NAV Space advances ONLY with the deck focused (unfocused, the page must
// still scroll); the index is derived from the H1s and jumps correctly
// I ZOOM fills the viewport as far as 16:9 allows — not "N times bigger", a
// threshold I could quietly lower until the test went green
// J SCALE the slide's CONTENT fills its box: never clipped, never overflowing.
// This is the gate whose absence hid a real bug — every other check
// measured the BOX, and the box was always right while the content
// stayed 980px inside a 328px phone. Measure the thing you claim.
//
// Usage: node gates/served.mjs <url>
import { chromium } from '../lib/paths.mjs'
const URL_ = process.argv[2]
if (!URL_) { console.error('uso: node gates/served.mjs <url-de-la-pagina>'); process.exit(2) }
const W = 980, H = 552
const browser = await chromium.launch()
const out = {}
let ok = true
const fail = (k, msg) => { out[k] = 'FAIL — ' + msg; ok = false }
// ── F: the DECK must add no third-party origin, and break nothing ─────────────────────
//
// Not "the page makes zero external requests": measured, the SITE itself pulls IBM Plex
// from Google Fonts on every page. That is the site's business, pre-dating this work, and
// a gate that failed on it would be blaming the deck for someone else's request. What the
// deck owes is that it adds NONE — so diff against a control page of the same site.
{
const origins = async (url) => {
const p = await browser.newPage({ viewport: { width: 1280, height: 900 } })
const hosts = new Set(), broken = []
p.on('request', r => {
const h = new global.URL(r.url()).host
if (h && !/^(localhost|127\.0\.0\.1)/.test(h)) hosts.add(h)
})
p.on('response', r => { if (r.status() >= 400) broken.push(r.status() + ' ' + r.url()) })
await p.goto(url, { waitUntil: 'networkidle' })
await p.evaluate(() => document.fonts.ready)
await p.waitForTimeout(800)
const fonts = await p.evaluate(() => ({
nunito: document.fonts.check('400 16px "Nunito Sans"'),
victor: document.fonts.check('400 16px "Victor Mono"'),
}))
await p.close()
return { hosts, broken, fonts }
}
const control = new global.URL(URL_)
control.pathname = '/'
const base = await origins(control.href)
const deck = await origins(URL_)
// Same discipline for 4xx: a broken resource the SITE already ships is not the deck's
// failure. Measured: /public/r/glosario-en.json 404s on every English page of the site —
// the English glossary simply does not exist. Report it, loudly; do not fail the deck
// for it, and do not bury it either.
const isDeckOwned = (line) => line.includes('/images/decks/')
const deckBroken = deck.broken.filter(isDeckOwned)
const siteBroken = deck.broken.filter(l => !isDeckOwned(l))
const added = [...deck.hosts].filter(h => !base.hosts.has(h))
if (added.length) fail('F_fuentes', `el deck añade ${added.length} origen(es) externo(s): ${added.join(', ')}`)
else if (deckBroken.length) fail('F_fuentes', `${deckBroken.length} recursos del deck rotos: ${deckBroken.slice(0, 3).join(', ')}`)
else if (!deck.fonts.nunito || !deck.fonts.victor) fail('F_fuentes', 'las fuentes auto-hospedadas del deck no cargan')
else out.F_fuentes = 'PASS — el deck no añade ningún origen externo, 0 recursos propios rotos, fuentes auto-hospedadas cargadas'
if (base.hosts.size) {
out['_terceros_del_SITIO'] = `${[...base.hosts].join(', ')} — presentes en toda la web, ajenos al deck`
}
if (siteBroken.length) {
out['_ROTO_EN_EL_SITIO'] = siteBroken.join(' · ') + ' ← no es del deck, pero está roto'
}
}
// ── E: slides rendered + Ctrl+F reach per mode ────────────────────────────────────────
{
const p = await browser.newPage({ viewport: { width: 1280, height: 900 } })
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(900)
const probe = (mode) => p.evaluate((m) => {
const view = document.querySelector('.deck-view')
if (m) view.querySelector(`[data-set-mode="${m}"]`).click()
const d = document.querySelector('.deck-read')
const box = d.getBoundingClientRect()
const slides = [...d.querySelectorAll('.deck-slide')]
const laid = slides.filter(s => { const r = s.getBoundingClientRect(); return r.height > 100 && r.width > 100 })
const reach = laid.filter(s => s.getBoundingClientRect().bottom - box.top <= box.height + 1)
return { inDom: slides.length, reachable: reach.length, words: d.innerText.split(/\s+/).filter(Boolean).length }
}, mode)
const served = await probe(null)
const read = await probe('read')
const slider = await probe('slider')
if (!read.inDom) fail('E_slides', 'no hay ninguna slide en la página')
else if (read.reachable !== read.inDom) fail('E_slides', `solo ${read.reachable}/${read.inDom} alcanzables en modo lectura`)
else if (read.words < 500) fail('E_slides', `solo ${read.words} palabras — ¿sigue siendo el carrusel de imágenes?`)
else out.E_slides = `PASS — lectura: ${read.reachable}/${read.inDom} slides, ${read.words} palabras`
// Not a verdict, a fact kept in plain sight: this is the price of the slider default.
out['_alcance_de_Ctrl+F'] = `tal como se sirve: ${served.words} palabras · Leer todo: ${read.words} · Diapositivas: ${slider.words}`
out['_nota'] = 'Las slides están SIEMPRE en el DOM (indexables). El modo solo cambia lo que el LECTOR alcanza.'
await p.close()
}
// ── H: keyboard + derived index ───────────────────────────────────────────────────────
{
const p = await browser.newPage({ viewport: { width: 1280, height: 800 } })
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(700)
const count = () => p.evaluate(() => document.querySelector('.deck-count').textContent.trim())
const toc = await p.evaluate(() => [...document.querySelectorAll('.deck-toc-link')].map(l => l.textContent.replace(/\s+/g, ' ').trim()))
const h1s = await p.evaluate(() => [...document.querySelectorAll('.deck-slide')].map(s => s.querySelector('h1,h2,h3')?.textContent.trim() ?? ''))
const derived = toc.length === h1s.length && toc.every((t, k) => !h1s[k] || t.includes(h1s[k].slice(0, 16)))
await p.evaluate(() => { document.querySelector('[data-set-mode="slider"]').click(); document.querySelector('.deck-view').focus() })
const c0 = await count()
await p.keyboard.press(' '); await p.waitForTimeout(150)
const c1 = await count()
await p.keyboard.down('Shift'); await p.keyboard.press(' '); await p.keyboard.up('Shift'); await p.waitForTimeout(150)
const c2 = await count()
// Space with the deck NOT focused must leave the page's own scrolling alone.
await p.evaluate(() => { document.querySelector('.deck-view').blur(); document.body.tabIndex = -1; document.body.focus(); window.scrollTo(0, 0) })
const y0 = await p.evaluate(() => window.scrollY)
await p.keyboard.press(' '); await p.waitForTimeout(400)
const y1 = await p.evaluate(() => window.scrollY)
const cBlur = await count()
// The index is a <nav> now, shown/hidden from the bar — no <details> to expand.
await p.evaluate(() => {
const v = document.getElementById('deck-view')
if (v.dataset.toc !== 'on') v.querySelector('.deck-toc-toggle').click()
v.querySelector('.deck-toc-link[data-goto="4"]').click()
})
await p.waitForTimeout(250)
const jumped = await count()
const adv = c1 !== c0, back = c2 === c0
const scrolls = y1 > y0 && cBlur === c2
const jumps = jumped.startsWith('5 ')
if (!adv) fail('H_navegacion', `la barra espaciadora no avanza (${c0}${c1})`)
else if (!back) fail('H_navegacion', `Shift+Espacio no retrocede (${c2})`)
else if (!scrolls) fail('H_navegacion', `sin foco, la barra espaciadora no scrollea la página (scrollY ${y0}${y1})`)
else if (!derived) fail('H_navegacion', 'el índice no coincide con los H1 de las slides')
else if (!jumps) fail('H_navegacion', `el índice no salta a la slide correcta (${jumped})`)
else out.H_navegacion = `PASS — espacio ${c0}${c1}, sin foco scrollea, índice de ${toc.length} derivado de los H1`
await p.close()
}
// ── I + J: nothing is clipped, and zoom fills the viewport ────────────────────────────
//
// J does NOT assert the card is 980x552. Slidev's frame is 552px tall and 12 of this deck's
// 16 slides hold more — up to 672px — so a rigid 16:9 box cut three quarters of the deck, and
// Slidev cuts them too: nobody had noticed, because until now the slides were PNGs of
// themselves. The card grows to its measured content. What J asserts is the property that
// actually matters and cannot be tuned away: NO CONTENT IS LOST — horizontally or vertically.
{
const VIEWPORTS = [
{ name: 'monitor 1920', width: 1920, height: 1080 },
{ name: 'portátil 1440', width: 1440, height: 900 },
{ name: 'tablet 820', width: 820, height: 1180 },
{ name: 'móvil 390', width: 390, height: 844 },
]
const clipped = [], zoom = []
for (const v of VIEWPORTS) {
const p = await browser.newPage({ viewport: { width: v.width, height: v.height } })
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(2200) // deja aterrizar las fuentes: re-miden
await p.evaluate(() => document.querySelector('[data-set-mode="read"]').click())
await p.waitForTimeout(600)
// Geometry alone is not enough: getBoundingClientRect reports a rect whether or not an
// ancestor is clipping the element. Slidev's .print-slide-container is height:552 +
// overflow:hidden, and it cut the content while every rect said it fitted. So check the
// clipping ancestor too, not just the numbers.
const clipper = await p.evaluate(() => {
const c = document.querySelector('.deck-slide .print-slide-container')
return getComputedStyle(c).overflow
})
if (clipper !== 'visible') { clipped.push({ v: v.name, cut: [{ slide: 0, overV: 0, overH: 0 }],
why: `.print-slide-container recorta (overflow: ${clipper})` }) }
const cut = await p.evaluate(() => {
return [...document.querySelectorAll('.deck-slide')].map((s, i) => {
const page = s.querySelector('.slidev-page')
const top = page.getBoundingClientRect().top
const left = page.getBoundingClientRect().left
const box = s.getBoundingClientRect()
let maxB = 0, maxR = 0
page.querySelectorAll('*').forEach(c => {
const r = c.getBoundingClientRect()
if (r.bottom - top > maxB) maxB = r.bottom - top
if (r.right - left > maxR) maxR = r.right - left
})
return { slide: i + 1, overV: Math.round(maxB - box.height), overH: Math.round(maxR - box.width) }
}).filter(r => r.overV > 2 || r.overH > 2)
})
if (cut.length) clipped.push({ v: v.name, cut })
await p.evaluate(() => document.querySelector('.deck-slide[data-active] .deck-zoom').click())
await p.waitForTimeout(400)
const z = await p.evaluate(() => {
const s = document.querySelector('.deck-slide[data-active]')
const r = s.getBoundingClientRect()
return { top: r.top, bottom: r.bottom, right: r.right, left: r.left, w: r.width }
})
const fits = z.top >= -1 && z.bottom <= v.height + 1 && z.left >= -1 && z.right <= v.width + 1
zoom.push({ v: v.name, fits, top: Math.round(z.top), w: Math.round(z.w) })
await p.close()
}
if (clipped.length) {
fail('J_nada_recortado', clipped.map(c => c.why
? `${c.v}: ${c.why}`
: `${c.v}: ${c.cut.length} slide(s) recortadas (${c.cut.slice(0,3).map(x => `#${x.slide} +${Math.max(x.overV,x.overH)}px`).join(', ')})`).join('; '))
} else out.J_nada_recortado = `PASS — ninguna slide pierde contenido en ${VIEWPORTS.length} viewports`
const badZoom = zoom.filter(z => !z.fits)
if (badZoom.length) {
fail('I_zoom', badZoom.map(z => `${z.v}: se sale del viewport (top=${z.top}px)`).join('; '))
} else out.I_zoom = `PASS — el zoom cabe entero en ${zoom.length} viewports (top ${zoom[0].top}px)`
}
// ── L: the sticky bar must carry the page's REAL background ───────────────────────────
// It sits over the deck as you scroll, so a transparent bar shows the slides through it and
// a wrong-coloured one is a band that belongs to no one. Measured: `background: Canvas`
// painted rgb(18,18,18) on a rgb(17,24,39) page — a black stripe across a navy site.
{
const p = await browser.newPage({ viewport: { width: 1440, height: 900 } })
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(1300)
const m = await p.evaluate(() => {
const bar = document.querySelector('.deck-bar')
const deck = document.getElementById('deck-view')
let e = deck.parentElement, page = null
while (e && !page) {
const bg = getComputedStyle(e).backgroundColor
if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') page = bg
e = e.parentElement
}
return { bar: getComputedStyle(bar).backgroundColor, page }
})
const transparent = m.bar === 'rgba(0, 0, 0, 0)' || m.bar === 'transparent'
if (transparent) fail('L_barra_pegajosa', 'la barra es transparente: las slides se ven a través al scrollear')
else if (m.bar !== m.page) fail('L_barra_pegajosa', `la barra (${m.bar}) no lleva el fondo de la página (${m.page})`)
else out.L_barra_pegajosa = `PASS — la barra lleva el fondo real de la página (${m.page})`
await p.close()
}
// ── K: the code-copy buttons Slidev shipped must actually copy ────────────────────────
// They survive the capture — 32 of them — but their handler was Vue and did not. And the
// button arrived 0x0: UnoCSS's preflight zeroes button padding and the icon has no
// intrinsic size, so its hit area was nothing. It was in the DOM and no human could click it.
{
const ctx = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'],
viewport: { width: 1440, height: 950 } })
const p = await ctx.newPage()
await p.goto(URL_, { waitUntil: 'networkidle' })
await p.waitForTimeout(1200)
await p.evaluate(() => { const w = document.getElementById('cookie-consent-wrapper'); if (w) w.remove() })
await p.evaluate(() => document.querySelector('[data-set-mode="read"]').click())
await p.waitForTimeout(500)
const hit = await p.evaluate(() => {
const b = document.querySelector('.slidev-code-copy')
if (!b) return null
const r = b.getBoundingClientRect()
return { w: Math.round(r.width), h: Math.round(r.height) }
})
if (!hit) fail('K_copiar_codigo', 'no hay botones de copia en el DOM')
else if (hit.w < 16 || hit.h < 16) fail('K_copiar_codigo', `el botón mide ${hit.w}x${hit.h}: nadie puede pulsarlo`)
else {
await p.bringToFront()
const wrap = p.locator('.slidev-code-wrapper').first()
await wrap.scrollIntoViewIfNeeded(); await wrap.hover(); await p.waitForTimeout(200)
await p.locator('.slidev-code-copy').first().click()
await p.waitForTimeout(500)
const clip = await p.evaluate(() => navigator.clipboard.readText())
const lines = clip ? clip.split('\n').length : 0
if (lines < 2) fail('K_copiar_codigo', `el portapapeles trae ${lines} línea(s) — ¿se perdieron los saltos?`)
else out.K_copiar_codigo = `PASS — botón ${hit.w}x${hit.h}px, ${lines} líneas copiadas con saltos reales`
}
await ctx.close()
}
await browser.close()
console.log(JSON.stringify(out, null, 2))
process.exit(ok ? 0 : 1)

View file

@ -0,0 +1,81 @@
#!/usr/bin/env nu
# Project the Slidev decks onto the site as real HTML — text, not pixels.
#
# The site's deck pages used to be a hand-written carousel of exported PNGs: the words of
# every slide were an IMAGE of words. Nothing indexed them, no glossary tooltip could
# reach them, no screen reader read them, nobody could select or translate them, and the
# page drifted from the source the moment a slide changed.
#
# The deck is NOT re-parsed. Slidev's markdown is not standard markdown, and its meaning
# does not live in the markdown anyway — it lives in a compiled bundle of UnoCSS
# utilities, theme rules and <style scoped> blocks. Re-implementing that is re-implementing
# a renderer. So Slidev renders, and we capture what it produced (its /print route, the
# same one `slidev export` drives) and re-home it in the site.
#
# Usage:
# nu scripts/decks/gen-deck-pages.nu # build + project + gate
# nu scripts/decks/gen-deck-pages.nu --dry-run # project nothing, report
# nu scripts/decks/gen-deck-pages.nu check --url <page> # gate a served page
def outreach [] { $env.FILE_PWD | path dirname | path dirname }
def presentation [] { [(outreach) "presentation"] | path join }
# Slidev must have produced a dist/ — the capture reads the browser's applied CSS from it,
# not the source. Without a build there is nothing to project.
def ensure-build [] {
let dist = [(presentation) "dist" "index.html"] | path join
if not ($dist | path exists) {
print $"(ansi yellow)no hay build; ejecutando 'pnpm build' en presentation/(ansi reset)"
cd (presentation)
^pnpm build
}
}
def main [--dry-run] {
ensure-build
let flags = if $dry_run { ["--dry-run"] } else { [] }
let script = [(outreach) "scripts" "decks" "lib" "project.mjs"] | path join
cd (presentation)
let out = (^node $script ...$flags | complete)
if $out.exit_code != 0 {
print $out.stdout
print $"(ansi red)($out.stderr)(ansi reset)"
error make { msg: "la proyección del deck falló" }
}
let report = ($out.stdout | from json)
print ($report.decks | select id slides css assets fuentes | table)
if $dry_run { return }
for d in $report.decks {
check-artifact $d.id
}
print $"(ansi green)decks proyectados.(ansi reset) Verifica la PÁGINA servida con: nu scripts/decks/gen-deck-pages.nu check --url <url>"
}
# Gate the CSS artifact: containment, fidelity, no inbound leak, glossary decoration.
def check-artifact [id: string] {
let script = [(outreach) "scripts" "decks" "gates" "artifact.mjs"] | path join
cd (presentation)
let r = (^node $script $id | complete)
print $r.stdout
if $r.exit_code != 0 { error make { msg: $"puertas del artefacto FALLAN para ($id)" } }
}
# Gate the page as served. Verifying the file proves nothing: the site's content pipeline
# sits between this generator and the reader.
def "main check" [--url: string] {
if ($url | is-empty) { error make { msg: "check necesita --url <url-de-la-pagina>" } }
let script = [(outreach) "scripts" "decks" "gates" "served.mjs"] | path join
cd (presentation)
let r = (^node $script $url | complete)
print $r.stdout
if $r.exit_code != 0 {
print $"(ansi red)($r.stderr)(ansi reset)"
error make { msg: "puertas de la página servida FALLAN" }
}
print $"(ansi green)puertas de la página servida: OK(ansi reset)"
}

View file

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

View file

@ -0,0 +1,77 @@
// Capture a built Slidev deck: its rendered DOM and the CSS actually in effect.
//
// The deck is NOT re-parsed. Slidev's markdown is not standard markdown (::right::,
// per-slide frontmatter, v-clicks) and its meaning does not live in the markdown at all
// — it lives in a compiled bundle of UnoCSS utilities, theme rules and <style scoped>
// blocks. Re-implementing that is re-implementing a renderer. So we let Slidev render,
// and take what it produced.
//
// The /print route lays every slide out at once with all clicks revealed — it is what
// `slidev export` drives internally.
//
// The CSS is read from document.styleSheets, not from disk: the build emits 17
// stylesheets, index.html links 2, and Vite loads the rest as route chunks. Picking
// files by hand is guesswork; the browser knows exactly what it applied, in cascade
// order. (Cross-origin sheets — Google Fonts — cannot be read this way and are reported,
// never silently dropped; fetch-fonts.mjs self-hosts them.)
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { extname, join, normalize } from 'node:path'
import { DIST, chromium } from './paths.mjs'
const MIME = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.webp': 'image/webp', '.gif': 'image/gif', '.woff': 'font/woff', '.woff2': 'font/woff2',
'.ttf': 'font/ttf',
}
async function serveDist() {
const server = createServer(async (req, res) => {
const p = join(DIST, normalize(new URL(req.url, 'http://x').pathname))
const file = existsSync(p) && extname(p) ? p : join(DIST, 'index.html')
res.setHeader('content-type', MIME[extname(file)] ?? 'application/octet-stream')
res.end(await readFile(file))
})
await new Promise(r => server.listen(0, r))
return { server, port: server.address().port }
}
export async function capture() {
if (!existsSync(join(DIST, 'index.html'))) {
throw new Error(`no hay build en ${DIST} — ejecuta 'pnpm build' en outreach/presentation primero`)
}
const { server, port } = await serveDist()
const browser = await chromium.launch()
try {
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
await page.goto(`http://localhost:${port}/print`, { waitUntil: 'networkidle' })
await page.waitForTimeout(4000) // Shiki + fonts + click resolution
const out = await page.evaluate(() => {
const parts = [], external = []
for (const s of document.styleSheets) {
try {
parts.push([...s.cssRules].map(r => r.cssText).join('\n'))
} catch {
external.push(s.href) // cross-origin: reported, not dropped
}
}
const content = document.querySelector('#print-content')
const slides = content ? content.querySelectorAll('.print-slide-container') : []
return {
html: content?.innerHTML ?? '',
css: parts.join('\n'),
slideCount: slides.length,
externalSheets: external,
}
})
if (!out.slideCount) throw new Error('la ruta /print no produjo ninguna slide — ¿cambió Slidev?')
return out
} finally {
await browser.close()
server.close()
}
}

View file

@ -0,0 +1,73 @@
// Self-host the deck's webfonts.
//
// The Slidev build links a cross-origin Google Fonts stylesheet, so document.styleSheets
// cannot read it and the capture drops its @font-face rules with no error. On macOS
// nothing looks wrong — Avenir Next is a system font — and everywhere else the deck
// silently falls back to whatever the OS happens to have.
//
// Two facts, both measured:
// · Google serves only Nunito Sans and Victor Mono for that URL. It IGNORES
// `Avenir+Next` — a proprietary Linotype face, absent from Google Fonts and not
// licensable for self-hosting. It stays in the stack as a local preference; the
// stack's next entry is what we now guarantee for everyone else. (Decided 2026-07-14:
// Mac readers see Avenir Next, everyone else Nunito Sans — a conscious split.)
// · The User-Agent decides the format. A generic UA gets .ttf; a modern Chrome UA gets
// .woff2, roughly a third of the bytes.
import { writeFile, mkdir } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { DIST, postcss, publicBase, publicDir } from './paths.mjs'
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
// latin + latin-ext. Greek, cyrillic and vietnamese are subsets an es/en deck never
// types. Select on the range's own start codepoint — the file hashes carry no meaning,
// and guessing from them is how a whole family gets deleted by accident.
const LATIN = /^U\+0000-00FF/i
const LATIN_EXT = /^U\+0100-02BA/i
const wantedRange = (r) => LATIN.test(r.trim()) || LATIN_EXT.test(r.trim())
export async function fetchFonts(id, { apply = true } = {}) {
const index = await readFile(join(DIST, 'index.html'), 'utf8')
const href = index.match(/<link[^>]+href="(https:\/\/fonts\.googleapis\.com[^"]+)"/)?.[1]
if (!href) return { skipped: 'el build no enlaza Google Fonts', css: '', files: [] }
const res = await fetch(href.replaceAll('&amp;', '&'), { headers: { 'User-Agent': UA } })
if (!res.ok) throw new Error(`Google Fonts: HTTP ${res.status}`)
let css = await res.text()
const requested = [...href.matchAll(/family=([^:&]+)/g)].map(m => decodeURIComponent(m[1]).replace(/\+/g, ' '))
const served = [...new Set([...css.matchAll(/font-family:\s*'([^']+)'/g)].map(m => m[1]))]
const notServed = requested.filter(f => !served.includes(f))
const base = `${publicBase(id)}/fonts`
const dir = join(publicDir(id), 'fonts')
if (apply) await mkdir(dir, { recursive: true })
const root = postcss.parse(css)
const keep = []
root.walkAtRules('font-face', at => {
const src = at.toString()
const range = src.match(/unicode-range:\s*([^;]+)/i)?.[1]
if (range && !wantedRange(range)) { at.remove(); return }
const url = src.match(/url\((https:\/\/fonts\.gstatic\.com[^)]+)\)/)?.[1]
if (url) keep.push(url)
})
const files = []
for (const url of [...new Set(keep)]) {
const name = basename(new URL(url).pathname)
if (apply) {
const r = await fetch(url, { headers: { 'User-Agent': UA } })
if (!r.ok) throw new Error(`fuente ${url}: HTTP ${r.status}`)
await writeFile(join(dir, name), Buffer.from(await r.arrayBuffer()))
}
files.push(name)
}
let out = root.toString()
for (const url of [...new Set(keep)]) out = out.replaceAll(url, `${base}/${basename(new URL(url).pathname)}`)
return { css: out, files, served, notServed }
}

View file

@ -0,0 +1,23 @@
// Shared path + dependency resolution for the deck projection.
//
// postcss and playwright-chromium are devDependencies of outreach/presentation/ (they
// are Slidev's neighbours, and Slidev is what we drive). Resolve them from there
// explicitly: pnpm is strict, so nothing is hoisted to a top-level node_modules.
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const HERE = dirname(fileURLToPath(import.meta.url)) // outreach/scripts/decks/lib
export const OUTREACH = resolve(HERE, '../../..') // outreach/
export const PRESENTATION = resolve(OUTREACH, 'presentation')
export const SITE = resolve(OUTREACH, 'site/site') // content/ + public/ live here
export const DIST = resolve(PRESENTATION, 'dist')
const require_ = createRequire(resolve(PRESENTATION, 'package.json'))
export const postcss = require_('postcss')
export const { chromium } = require_('playwright-chromium')
export const publicBase = (id) => `/images/decks/${id}`
export const publicDir = (id) => resolve(SITE, `public/images/decks/${id}`)
export const contentFile = (lang, id) => resolve(SITE, `content/resources/${lang}/decks/${id}.md`)

View file

@ -0,0 +1,53 @@
// Re-point every asset reference at the site's origin, and refuse to emit anything that
// would 404. A silently-missing image is precisely how the page goes back to lying about
// its own content — which is the failure this whole projection exists to end.
import { writeFile, mkdir, copyFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { basename, join } from 'node:path'
import { DIST, postcss, publicBase, publicDir } from './paths.mjs'
export async function projectAssets(html, css, id, { apply = true } = {}) {
const base = `${publicBase(id)}/assets`
const wanted = new Map()
const unresolved = []
const claim = (ref) => {
const clean = ref.trim().replace(/^["']|["']$/g, '')
if (/^(data:|https?:|#|mailto:)/i.test(clean)) return null // external / inline: not ours
if (!clean.startsWith('/')) { unresolved.push({ ref: clean, why: 'referencia relativa, sin base' }); return null }
const onDisk = join(DIST, clean)
if (!existsSync(onDisk)) { unresolved.push({ ref: clean, why: `no está en dist/` }); return null }
const pub = `${base}/${basename(clean)}`
wanted.set(clean, { onDisk, pub })
return pub
}
html = html
.replace(/(<img[^>]+src=")([^"]+)(")/g, (m, a, ref, z) => { const p = claim(ref); return p ? a + p + z : m })
.replace(/(background-image:\s*url\()([^)]+)(\))/g, (m, a, ref, z) => { const p = claim(ref); return p ? a + p + z : m })
const root = postcss.parse(css)
root.walkDecls(d => {
if (!d.value.includes('url(')) return
d.value = d.value.replace(/url\(([^)]+)\)/g, (m, ref) => {
const p = claim(ref)
return p ? `url("${p}")` : m
})
})
css = root.toString()
if (unresolved.length) {
const lines = unresolved.map(u => ` ${u.ref}${u.why}`).join('\n')
throw new Error(`${unresolved.length} referencia(s) sin resolver; no se emite un deck con assets rotos:\n${lines}`)
}
if (apply) {
const dest = join(publicDir(id), 'assets')
await mkdir(dest, { recursive: true })
for (const [, v] of wanted) await copyFile(v.onDisk, join(dest, basename(v.pub)))
}
return { html, css, copied: [...wanted.values()].map(v => v.pub) }
}

View file

@ -0,0 +1,114 @@
// Drive the whole projection: capture → prune → assets → fonts → scope → body → write.
//
// Usage: node lib/project.mjs [--dry-run]
//
// Reads decks.ncl. Writes, per deck id:
// site/public/images/decks/<id>/deck.css scoped bundle + self-hosted @font-face
// site/public/images/decks/<id>/assets/* images the slides reference
// site/public/images/decks/<id>/fonts/* woff2, latin + latin-ext
// site/content/resources/<lang>/decks/<id>.md body replaced, FRONTMATTER PRESERVED
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import vm from 'node:vm'
import { join, dirname } from 'node:path'
import { capture } from './capture.mjs'
import { scopeCss, pruneUnused } from './scope-css.mjs'
import { projectAssets } from './project-assets.mjs'
import { fetchFonts } from './fetch-fonts.mjs'
import { buildBody } from './build-body.mjs'
import { OUTREACH, SITE, publicBase, publicDir, contentFile } from './paths.mjs'
const DRY = process.argv.includes('--dry-run')
const apply = !DRY
const manifest = JSON.parse(
execFileSync('nickel', ['export', '--format', 'json', join(OUTREACH, 'scripts/decks/decks.ncl')], { encoding: 'utf8' })
)
const { boundary_allow: ALLOW, slide: { width: W, height: H }, i18n: I18N } = manifest
// The generated body is fenced. Anything outside the markers is the author's and is left
// untouched — the frontmatter above all, which carries title, slug, thumbnail and og_image.
const BEGIN = '<!-- BEGIN deck-projection — generado por scripts/decks; no editar a mano -->'
const END = '<!-- END deck-projection -->'
function replaceBody(existing, generated) {
const fm = existing.match(/^---\n[\s\S]*?\n---\n/)
if (!fm) throw new Error('la página de destino no tiene frontmatter; no la sobrescribo a ciegas')
return fm[0] + '\n' + BEGIN + '\n' + generated + '\n' + END + '\n'
}
const report = []
for (const deck of manifest.decks) {
const { id, pages } = deck
const cap = await capture()
const { css: prunedCss, pruned, usesKatex, usesTwoslash } = pruneUnused(cap.css, cap.html)
const assets = await projectAssets(cap.html, prunedCss, id, { apply })
const fonts = await fetchFonts(id, { apply })
const { css: scoped, stats } = scopeCss(assets.css, { scope: '.deck-read', allow: ALLOW })
// One capture, N pages. The deck BODY is shared — it is not translated — but the chrome
// around it is the site's UI and speaks the page's language, so it is built per page.
const views = Object.fromEntries(pages.map(p => [p.lang,
buildBody(assets.html, { id, count: cap.slideCount, W, H, t: I18N[p.lang] })]))
const view = views[pages[0].lang]
// Syntax-check what we emit. A code generator can write an INVALID file and nobody
// notices: it lands on disk, the server returns 200, and only the browser rejects it —
// in a console no one is reading. Measured: an escaped \n inside the JS template literal
// became a real newline inside a regex, deck.js stopped parsing, and every control on the
// page went dead while the deck still rendered perfectly. That is the generator's bug,
// and it must break AT GENERATION, not in the reader's browser.
try {
new vm.Script(view.js, { filename: `${id}/deck.js` })
} catch (e) {
throw new Error(`el deck.js generado para ${id} no parsea: ${e.message}`)
}
const bundle = [fonts.css, scoped, view.css].filter(Boolean).join('\n')
// CSS and JS go to EXTERNAL files, referenced from the body.
//
// Not an optimisation — a correctness requirement. An inline <script> inside a markdown
// body is handed to a typographer: measured on the real site, the renderer injected <p>
// at the first blank line, turned every ' into a curly ', escaped && into &amp;&amp;,
// and promoted an indented block to <pre><code>. The result was `SyntaxError:
// Unexpected token '<'` and a deck that rendered perfectly and did nothing. The fix is
// not to escape the JS — that is fighting the renderer — but to take it out of the
// markdown. Cacheability is the bonus.
//
// A <link>/<script src> in the BODY (rather than the head) is what the content pipeline
// allows: a markdown page cannot reach <head>. Browsers honour both. The cleaner route —
// frontmatter declaring assets that post.j2 hoists into <head> — needs a template change;
// it is recorded in the plan, not smuggled in here.
const bodyFor = (v) => [
`<link rel="stylesheet" href="${publicBase(id)}/deck.css">`,
v.html,
`<script src="${publicBase(id)}/deck.js" defer></script>`,
].join('\n')
if (apply) {
await mkdir(publicDir(id), { recursive: true })
await writeFile(join(publicDir(id), 'deck.css'), bundle)
await writeFile(join(publicDir(id), 'deck.js'), view.js)
for (const p of pages) {
const file = contentFile(p.lang, id)
const existing = await readFile(file, 'utf8')
await writeFile(file, replaceBody(existing, bodyFor(views[p.lang])))
}
}
report.push({
id,
slides: cap.slideCount,
paginas: pages.map(p => `${p.lang}:/${p.lang === 'es' ? 'recursos' : 'resources'}/decks/${p.slug}`),
css: `${Math.round(bundle.length / 1024)} KB (${stats.rules} reglas)`,
podado: `katex ${pruned.katexRules}+${pruned.katexFontFace} · twoslash ${pruned.twoslashRules}`,
guard: `${stats.resetsExempted} resets exentos · ${stats.viewportPropsDropped} props de viewport descartadas`,
assets: assets.copied.length,
fuentes: `${fonts.files.length} woff2` + (fonts.notServed?.length ? ` · SIN SERVIR: ${fonts.notServed.join(', ')}` : ''),
hojasExternas: cap.externalSheets,
})
}
console.log(JSON.stringify({ modo: DRY ? 'DRY RUN — no se ha escrito nada' : 'aplicado', decks: report }, null, 2))

View file

@ -0,0 +1,116 @@
// Scope the captured Slidev bundle so it cannot reach out of the deck, and the site
// cannot reach in — except where the site is explicitly entitled to.
//
// Three traps, each one measured, each one silent if you get it wrong:
//
// 1. ROOT COLLAPSE. :root/html/body must be REPLACED by the scope, not prefixed
// (`.deck-read html{}` matches nothing, and dropping :root strips every custom
// property the deck resolves against). But collapsing them also imports what those
// selectors mean as a DOCUMENT: Slidev ships `html,body{height:100%;overflow:hidden}`
// — a full-viewport, non-scrolling SPA canvas. On an embedded block that is a cage:
// it clipped the deck at 900px and swallowed 14 of 16 slides, with no error.
//
// 2. THE INBOUND GUARD. UnoCSS's preflight normalizes UA defaults; it does not defend
// against the host page's own element rules (`code{background:#eee}` sails straight
// through). One `all: revert` closes the class — but it must NOT be blind: the site
// deliberately decorates deck text (glossary terms are the whole point).
//
// 3. SPECIFICITY. Write `:not(:where(a,b))`, never `:not(a):not(b)`. A plain :not()
// inherits its argument's specificity, so every entry added to the allow-list would
// raise the guard a notch and start outranking the projection's own rules — measured:
// two exemptions pushed it to (0,3,0) and it reverted the slide-scaling transform.
// :where() contributes zero, pinning the guard at (0,1,0) however long the list grows.
import { postcss } from './paths.mjs'
const { list } = postcss
const ROOT_RE = /^(:root|html|body|html\s+body|html:root)$/i
// Only the document root can meaningfully claim the viewport. An embedded block cannot.
const VIEWPORT_PROPS =
/^(height|min-height|max-height|width|min-width|max-width|overflow(-[xy])?|position|top|right|bottom|left|inset)$/i
// Reset rules are exactly those whose rightmost compound is a universal or a bare tag.
// Real deck styling always carries a class and never targets an allow-listed element.
const BARE_COMPOUND = /(^|[\s>+~])(\*|[a-zA-Z][\w-]*)$/
export function scopeCss(css, { scope = '.deck-read', allow = [] } = {}) {
const exempt = allow.length ? `:not(:where(${allow.join(',')}))` : ''
const stats = { rules: 0, rootCollapsed: 0, darkCollapsed: 0, viewportPropsDropped: 0, resetsExempted: 0 }
const scopeSelector = (sel) => {
const s = sel.trim()
if (s.startsWith(scope)) return s
if (ROOT_RE.test(s)) { stats.rootCollapsed++; return scope }
// `html.dark` / `.dark x` → the wrapper carries `dark`. Preserving the distinction
// (rather than force-enabling dark) keeps the bundle's own light/dark cascade intact
// and independent of whatever theme the site is in.
const dark = s.match(/^(?:html)?\.dark\b(.*)$/i)
if (dark) {
stats.darkCollapsed++
const rest = dark[1].trim()
return rest ? `${scope}.dark ${rest}` : `${scope}.dark`
}
const rooted = s.match(/^(?:html|body)\s+(.+)$/i)
if (rooted) { stats.rootCollapsed++; return `${scope} ${rooted[1]}` }
return `${scope} ${s}`
}
const exemptResets = (sel) => {
if (!exempt || !BARE_COMPOUND.test(sel)) return sel
stats.resetsExempted++
return sel.replace(BARE_COMPOUND, `$1$2${exempt}`)
}
const plugin = {
postcssPlugin: 'scope-deck',
Once(root) {
root.walkRules(rule => {
// Keyframe steps (0%, from, to) and @font-face descriptors are not selectors.
const at = rule.parent?.type === 'atrule' ? rule.parent.name.toLowerCase() : null
if (at && (at.endsWith('keyframes') || at === 'font-face')) return
stats.rules++
const parts = list.comma(rule.selector)
const cameFromRoot = parts.some(p => ROOT_RE.test(p.trim()))
rule.selector = parts.map(scopeSelector).map(exemptResets).join(',')
if (cameFromRoot) {
rule.walkDecls(d => {
if (VIEWPORT_PROPS.test(d.prop)) { d.remove(); stats.viewportPropsDropped++ }
})
if (!rule.nodes.length) rule.remove()
}
})
},
}
const guard = exempt ? `${scope},${scope} *${exempt}{all:revert}\n` : ''
const out = postcss([plugin]).process(css, { from: undefined }).css
return { css: guard + out, stats }
}
// The deck ships machinery it never uses. Prune ONLY what the DOM demonstrably lacks —
// guessing here silently strips a live style. KaTeX alone carries 20 @font-face blocks
// pointing at /assets/KaTeX_*.woff2, which on the site would be 20 requests to 404.
export function pruneUnused(css, html) {
const usesKatex = /class="[^"]*\bkatex\b/.test(html)
const usesTwoslash = /class="[^"]*\btwoslash\b/.test(html)
const pruned = { katexFontFace: 0, katexRules: 0, twoslashRules: 0 }
const root = postcss.parse(css)
if (!usesKatex) {
root.walkAtRules('font-face', at => {
if (/font-family:\s*["']?KaTeX_/i.test(at.toString())) { at.remove(); pruned.katexFontFace++ }
})
root.walkRules(r => { if (/\bkatex\b/i.test(r.selector)) { r.remove(); pruned.katexRules++ } })
}
if (!usesTwoslash) {
root.walkRules(r => { if (/\btwoslash\b/i.test(r.selector)) { r.remove(); pruned.twoslashRules++ } })
}
return { css: root.toString(), pruned, usesKatex, usesTwoslash }
}

View file

@ -58,9 +58,15 @@ content type="" lang="":
# excluded (site/r holds only stale copies that would overwrite the fresh originals
# gen-adr-map / gen-about-pages / gen-taglines just wrote) — and --exclude also
# protects them from --delete, since they legitimately exist only on this side.
# `glosario-*.json` joins the exclude list for the same reason the other four are on it: the
# nu generators own it in site/public/r, content_processor knows nothing about it, and an
# unexcluded --delete PRUNES IT. Measured, not feared: `just content` deleted it and the
# tooltip layer 404'd on its first publish. That is expediente 69/58 — the mirror that reverts
# its own work — on a path its cure never covered.
rsync -a --delete \
--exclude about.json --exclude adr-map.json \
--exclude taglines.json --exclude content_graph.json \
--exclude 'glosario-*.json' \
"{{ project_root }}/site/r/" "{{ project_root }}/site/public/r/"
# nu artifacts → serve tree, so `./run.sh` renders the current About/ADR map.
for f in about.json adr-map.json taglines.json content_graph.json; do
@ -97,6 +103,64 @@ graph daemon=project_url:
adr-map daemon=project_url:
nu "{{ project_root }}/scripts/build/gen-adr-map.nu" --root "{{ project_root }}/../.." --daemon "{{ daemon }}"
# The glosses were written in the registry "for a reader who does not know the term" and no
# surface ever showed them: /acerca-de said NCL and DAG to people who had met neither. This is the
# projection that ends that — and it is IN THE CHAIN, because a generator nobody calls is a
# generator that does not run (see adr-pages, eleven ADRs, four weeks).
# Project the term registry → site/public/r/glosario-<lang>.json (the tooltip's only source).
[no-cd]
glossary:
nu "{{ project_root }}/scripts/build/gen-glossary-json.nu" --root "{{ project_root }}/../.."
# Project the term registry → la página /conceptos (servida, no pintada con JS).
[no-cd]
conceptos:
nu "{{ project_root }}/scripts/build/gen-conceptos.nu" --root "{{ project_root }}/../.."
# Project the Slidev decks → real HTML on the deck pages. The words of a published slide used
# to be an IMAGE of words: nothing indexed them, no glossary tooltip could reach them, no screen
# reader read them, and the page drifted from the source the moment a slide changed. The deck is
# NOT re-parsed — Slidev renders it (its own /print route) and we capture what it produced.
# Lives in outreach/scripts/decks because it reads presentation/ and writes site/: it is the
# bridge, not the site's.
[no-cd]
decks:
nu "{{ project_root }}/../scripts/decks/gen-deck-pages.nu"
# Gate (on disk): the deck's CSS bundle cannot reach out of its wrapper, the site's CSS cannot
# reach in — except the glossary, which is entitled to and which is the whole point.
[no-cd]
decks-check:
#!/usr/bin/env bash
set -e
cd "{{ project_root }}/../presentation"
for id in $(nickel export --format json ../scripts/decks/decks.ncl | jq -r '.decks[].id'); do
node ../scripts/decks/gates/artifact.mjs "$id"
done
# Gate (served): the PAGE, not the file. 16/16 slides rendered, nothing clipped, the keyboard and
# the index drive it, the zoom fits, the copy buttons copy, no third-party origin added. Verifying
# the file would prove nothing — the site's content pipeline sits between generator and reader,
# and it is the pipeline that once ate an inline <script> whole.
[no-cd]
decks-served base_url="http://localhost:3030":
#!/usr/bin/env bash
set -e
BASE="{{ base_url }}"; BASE="${BASE#*=}"
cd "{{ project_root }}/../presentation"
node ../scripts/decks/gates/served.mjs "$BASE/recursos/decks/ontoref-ontologia-reflexion"
node ../scripts/decks/gates/served.mjs "$BASE/resources/decks/ontoref-ontology-and-reflection"
# Gate: la página de conceptos reproduce desde el registro — nadie edita un glosario a mano.
[no-cd]
conceptos-check:
nu "{{ project_root }}/scripts/build/gen-conceptos.nu" --root "{{ project_root }}/../.." --check
# Gate: the served glossary reproduces from the registry — nobody hand-edits a tooltip.
[no-cd]
glossary-check:
nu "{{ project_root }}/scripts/build/gen-glossary-json.nu" --root "{{ project_root }}/../.." --check
# The web-usable form of `onre diagram` (served at /images/, the static asset root).
# Referenced by the About graph block, so must run before about-json.
# Project the ontology (core.ncl) → site/public/images/ontoref-diagram.svg.
@ -104,6 +168,17 @@ adr-map daemon=project_url:
diagram:
nu "{{ project_root }}/scripts/build/gen-diagram.nu" --root "{{ project_root }}/../.." --out "{{ project_root }}/site/public/images/ontoref-diagram.svg"
nu "{{ project_root }}/scripts/build/gen-graph-page.nu" --root "{{ project_root }}/../.." --out "{{ project_root }}/site/public/images/ontoref-graph.html"
nu "{{ project_root }}/../../scripts/gen-architecture.nu" --root "{{ project_root }}/../.." --only outreach/site
# The home architecture SVG was a HAND COPY of scripts/templates/architecture.svg.tmpl with every
# {{{{TOKEN}}}} frozen: it shipped v0.1.7 / 41 practices / 163 edges / 56 nodes against a spine at
# 45 / 171 / 60, under a footer claiming to be a live projection. The generator already existed —
# it simply did not know about this target, and nothing gated it. --only keeps the blast radius
# here: a stale brand asset in assets/ is not this site's gate to fail.
# Gate: assert the home architecture SVG matches the spine it is generated from.
[no-cd]
architecture-check:
nu "{{ project_root }}/../../scripts/gen-architecture.nu" --root "{{ project_root }}/../.." --only outreach/site --check
# The ADR set moves with the spine, not with this repo, so the projection has to be in
# the build chain or it silently stops tracking it: ADR-059..069 sat unpublished for
@ -157,9 +232,17 @@ expedientes:
# the render instead of the source, which is how the prose the contract did not model
# came to live only in the generated file.
# Gate: assert every expediente .md body is reproducible from its typed CASE.
#
# The gate runs FIRST, on the real tree, so a real defect is reported as itself. THEN its ORACLE
# runs — because a gate nobody has seen REFUSE is not a gate (adr-072,
# falsify-against-the-new-capability). The oracle carries one negative case per axis the gate
# covers: the extent grows, a language goes missing, the served tree falls behind, the extent
# becomes unreadable. Each was falsified by hand once and the falsification thrown away; this is
# the residue that refuses the same failure tomorrow.
[no-cd]
expedientes-check:
nu "{{ project_root }}/scripts/build/gen-expediente.nu" --root "{{ project_root }}" --check
nu "{{ project_root }}/../../.ontoref/reflection/tests/test_expediente_coverage.nu"
# The standalone informe (site/public/images/expedientes/<lang>/expedientes.html) was
# hand-kept HTML with its cases inlined as a JS array, while the ARTICLES were already
@ -282,59 +365,97 @@ sync-site-images:
$files | each {|f| cp $f $dest }
print $"synced ($files | length) image\(s\) from ($src) → ($dest)"
# ── Templates ───────────────────────────────────────────────────────────────
# ── Templates & htmx runtime (the override chain) ───────────────────────────
#
# ONE rule governs both trees below, and it is the rule the implementation already
# applies to the framework: a file present in a higher layer WINS, a file absent from
# it is INHERITED from the layer below. The chain has three levels, not two:
#
# rustelo (framework) → website-htmx-rustelo (implementation) → outreach/site (this)
#
# The consumer's layer is site/_htmx/ — `_` prefixed like site/_public, and for the same
# reason: underscore is the tree you EDIT, its unprefixed twin is the tree that gets
# GENERATED. htmx-templates/ and htmx-assets/ are outputs. They are wiped and rebuilt on
# every run, so a file that lives only there lives nowhere — that is how the nav submenus
# were lost once already. Both targets keep their current paths (HTMX_TEMPLATE_PATH /
# HTMX_ASSETS_PATH, the PV deliverable); only the sources moved.
#
# site/_htmx/templates/** → htmx-templates/**
# site/_htmx/assets/** → htmx-assets/**
# Safe to re-run; overwrites htmx-templates/ from scratch — which is exactly why the
# site's own templates cannot live in the output. The assembler has two layers
# (framework defaults, then source-project templates) but the level chain has three:
# rustelo → website-htmx-rustelo → outreach/site. site/templates-overlay/ is the
# missing third slot: the only declared home for template work owned by THIS level.
# Anything edited straight into htmx-templates/ is destroyed on the next run — that is
# how the nav submenus were lost once already.
# Assemble htmx-templates: framework defaults → source-project templates → site overlay.
framework_templates := rustelo_root + "/crates/foundation/crates/rustelo_pages_htmx/templates"
impl_templates := source_project + "/crates/pages_htmx/templates"
site_templates := project_root + "/site/_htmx/templates"
framework_htmx := rustelo_root + "/templates/shared/htmx"
impl_htmx := source_project + "/templates/shared/htmx"
site_htmx := project_root + "/site/_htmx/assets"
# htmx.lock.toml is the framework's provenance record for the vendored runtime, not part
# of it; .gitkeep only exists to declare an empty consumer layer. Neither ships.
htmx_assets_exclude := "htmx.lock.toml,.gitkeep"
# Assemble htmx-templates: framework → implementation → site/_htmx/templates.
[no-cd]
templates:
nu "{{ source_project }}/scripts/build/assemble-htmx-templates.nu" \
--project-templates "{{ source_project }}/crates/pages_htmx/templates" \
--out "{{ project_root }}/htmx-templates"
cp -R "{{ project_root }}/site/templates-overlay/." "{{ project_root }}/htmx-templates/"
nu "{{ project_root }}/scripts/build/assemble-layers.nu" \
"{{ framework_templates }}" "{{ impl_templates }}" "{{ site_templates }}" \
--out "{{ project_root }}/htmx-templates" --label "htmx templates"
# Proves `just templates` is lossless: reassemble into a temp tree and diff it against
# the live one. Any difference means htmx-templates/ holds work that exists nowhere
# else and the next `just templates` (or `just sync`, which calls it) will delete it —
# the fix is to move that work into site/templates-overlay/, never to skip the run.
# Gate: assert htmx-templates/ is exactly reproducible from its declared sources.
# Materialize the htmx runtime (htmx.min.js + ext/*) into htmx-assets/, sibling of
# htmx-templates/. Shipped to the PV by content.nu (distro.ncl deliverable) and served at
# /assets/htmx/* via HTMX_ASSETS_PATH — it lives OUTSIDE site/public (the router mounts
# /assets/htmx as its own ServeDir). Dev masks this via the local-install wrapper's
# HTMX_ASSETS_PATH; prod has no such mount.
#
# It gets the SAME three layers as the templates: until now it had one (the framework),
# so an ext/ override shipped by the implementation could never reach the site, and the
# consumer had nowhere to put one at all.
# Assemble htmx-assets: framework → implementation → site/_htmx/assets.
[no-cd]
htmx-assets:
nu "{{ project_root }}/scripts/build/assemble-layers.nu" \
"{{ framework_htmx }}" "{{ impl_htmx }}" "{{ site_htmx }}" \
--out "{{ project_root }}/htmx-assets" --exclude "{{ htmx_assets_exclude }}" \
--label "htmx runtime"
# Proves the assembly is lossless: reassemble into a temp tree and diff it against the
# live one. Any difference means the target holds work that exists in no layer, and the
# next run (or `just sync`, which calls both) will delete it — the fix is to move that
# work into site/_htmx/, never to skip the run.
# Gate: assert htmx-templates/ is exactly reproducible from its declared layers.
[no-cd]
templates-check:
#!/usr/bin/env bash
set -e
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
nu "{{ source_project }}/scripts/build/assemble-htmx-templates.nu" \
--project-templates "{{ source_project }}/crates/pages_htmx/templates" \
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
nu "{{ project_root }}/scripts/build/assemble-layers.nu" \
"{{ framework_templates }}" "{{ impl_templates }}" "{{ site_templates }}" \
--out "$tmp" >/dev/null
cp -R "{{ project_root }}/site/templates-overlay/." "$tmp/"
if diff -r "{{ project_root }}/htmx-templates" "$tmp" >/dev/null 2>&1; then
echo "templates-check: htmx-templates/ is reproducible — no unwitnessed work"
else
echo "templates-check: DRIFT — htmx-templates/ holds work absent from its sources:"
echo "templates-check: DRIFT — htmx-templates/ holds work absent from site/_htmx/templates/:"
diff -rq "{{ project_root }}/htmx-templates" "$tmp" | sed "s|$tmp|<regenerated>|g"
exit 1
fi
# Safe to re-run; overwrites htmx-assets/ from scratch.
# Materialize the framework htmx runtime (htmx.min.js + ext/*) into a top-level
# htmx-assets/ tree (sibling of htmx-templates/). Shipped to the PV by content.nu
# (distro.ncl deliverable) and served at /assets/htmx/* via HTMX_ASSETS_PATH — the
# runtime lives OUTSIDE site/public (router mounts /assets/htmx as its own ServeDir,
# not from the public tree). Dev masks this via the local-install wrapper's
# HTMX_ASSETS_PATH; prod has no such mount. Source of truth: templates/shared/htmx.
# Gate: assert htmx-assets/ is exactly reproducible from its declared layers.
[no-cd]
htmx-assets:
rm -rf "{{ project_root }}/htmx-assets"
mkdir -p "{{ project_root }}/htmx-assets/ext"
cp -f "{{ rustelo_root }}/templates/shared/htmx/htmx.min.js" "{{ project_root }}/htmx-assets/htmx.min.js"
cp -R "{{ rustelo_root }}/templates/shared/htmx/ext/." "{{ project_root }}/htmx-assets/ext/"
assets-check:
#!/usr/bin/env bash
set -e
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
nu "{{ project_root }}/scripts/build/assemble-layers.nu" \
"{{ framework_htmx }}" "{{ impl_htmx }}" "{{ site_htmx }}" \
--out "$tmp" --exclude "{{ htmx_assets_exclude }}" >/dev/null
if diff -r "{{ project_root }}/htmx-assets" "$tmp" >/dev/null 2>&1; then
echo "assets-check: htmx-assets/ is reproducible — no unwitnessed work"
else
echo "assets-check: DRIFT — htmx-assets/ holds work absent from site/_htmx/assets/:"
diff -rq "{{ project_root }}/htmx-assets" "$tmp" | sed "s|$tmp|<regenerated>|g"
exit 1
fi
# ── Posts ─────────────────────────────────────────────────────────────────────
@ -354,6 +475,76 @@ posts-check:
nu "{{ project_root }}/scripts/build/linkify-site.nu" --check "{{ project_root }}/site/content/blog/**/*.md"
nu "{{ project_root }}/scripts/build/check-graph-coverage.nu" "{{ project_root }}"
# Every gate this repo owns, in one run. It does NOT stop at the first failure: a build
# that publishes nothing tells you everything that is wrong, not the first thing.
#
# Until now each of these was an orphan recipe — written by the expediente that motivated
# it and then invoked by nobody. A gate outside the chain is a private ritual, not a gate;
# it is the exact shape of the bug the 69/58 case is about (a generator existed, no recipe
# called it, eleven ADRs went unpublished for four weeks). So: `sync-full` ends here, and
# `just install-hooks` puts it in front of every commit that touches site/.
[no-cd]
check:
#!/usr/bin/env bash
fail=()
gates=(adr-check architecture-check glossary-check conceptos-check templates-check assets-check expedientes-check informe-check expedientes-parity posts-check decks-check)
for gate in "${gates[@]}"; do
just "$gate" > /tmp/gate-$$.log 2>&1 || fail+=("$gate")
sed 's/^/ /' /tmp/gate-$$.log | grep -v '^\s*nu \|^\s*#!' || true
rm -f /tmp/gate-$$.log
done
if [ ${#fail[@]} -eq 0 ]; then
echo "check: ${#gates[@]}/${#gates[@]} gates green — ON DISK. This is NOT proof of publication:"
echo " the server reads its content index INTO MEMORY at startup, so a tree it has"
echo " never re-read is a tree that, to every reader, does not exist."
echo " The page is the publication: just check-live <base-url>"
else
echo "check: FAILED — ${fail[*]}"
exit 1
fi
# THE OTHER HALF OF `check`, and the half that a reader would recognise.
#
# `check` asks whether the DISK is right. It cannot ask whether the SITE is right, and the
# difference is not academic: both trees said five cases, the grid rendered four, and only a
# restart fixed it — because the server had cached its index at boot. A gate that stops at the
# filesystem certifies a publication that nobody can see.
#
# So this is the witness at the end of the chain, and it asks the only question a reader can:
# IS THE CASE ON THE PAGE? It needs a running site, and if it cannot reach one it FAILS — it does
# not skip. "I could not look" is a finding, never a pass (adr-072).
[no-cd]
check-live base_url="http://localhost:3030":
#!/usr/bin/env bash
set -e
BASE="{{ base_url }}"; BASE="${BASE#*=}"
just expedientes-served "$BASE"
just decks-served "$BASE"
echo "check-live: the served surface renders what the tree says is published"
# Gate: every published case is RENDERED AS A CARD on the live grid — counting the card, never
# the string. The ids also live in the nav, the meta tags and two JSON blobs, so a page-wide grep
# reports a case as present while its card is missing. That grep is the check that once reported
# "5 cards" over a grid that was rendering four.
[no-cd]
expedientes-served base_url="http://localhost:3030":
#!/usr/bin/env bash
set -e
BASE="{{ base_url }}"; BASE="${BASE#*=}"
nu "{{ project_root }}/scripts/build/check-served-live.nu" --base-url "$BASE" --root "{{ project_root }}"
# Install the pre-commit gate into this repo (sets core.hooksPath). Undo with:
# git config --unset core.hooksPath
[no-cd]
install-hooks:
#!/usr/bin/env bash
set -e
# Absolute: the repo root is outreach/, the justfile lives in outreach/site/, and a
# relative hooksPath would resolve against the wrong one of the two.
git -C "{{ project_root }}" config core.hooksPath "{{ project_root }}/scripts/hooks"
chmod +x "{{ project_root }}/scripts/hooks/pre-commit"
echo "install-hooks: pre-commit → just check (only when the commit touches site/)"
# ── Sync ────────────────────────────────────────────────────────────────────
# Full resync: templates + htmx runtime + vendor assets + content indexes
@ -367,10 +558,12 @@ sync: templates htmx-assets rotator-assets content
# sync-artifacts LAST — the nu generators write to site/public/r, and only `content`
# copies them back to the serve tree; without this final leg a sync-full leaves
# site/r rendering the previous run's About and ADR map.
# check LAST — a build that cannot assert what it just produced is a reporter, and in
# this repo the check decides, never the reporter (ADR-066).
# Requires ONTOREF_NICKEL_IMPORT_PATH in .env.
# Full resync: projections → content indexes → derived artifacts → serve tree.
[no-cd]
sync-full: templates htmx-assets rotator-assets adr-pages expedientes informe catalog-content content graph adr-map about-json sync-artifacts
sync-full: templates htmx-assets rotator-assets adr-pages expedientes informe catalog-content decks content glossary conceptos graph adr-map about-json sync-artifacts check
# The nu generators own these four files in site/public/r (the deploy tree); the serve
# tree needs the same copies. `content` does this too, so a standalone `just content`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 247.73 83.02">
<defs>
<style>
.cls-1 {
fill: #f1f2f2;
font-family: SignPainter-HouseScript, SignPainter;
font-size: 62px;
}
.cls-2 {
letter-spacing: .02em;
}
.cls-3 {
letter-spacing: .03em;
}
.cls-4 {
letter-spacing: .02em;
}
.cls-5 {
letter-spacing: .02em;
}
.cls-6 {
letter-spacing: .02em;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<text class="cls-1" transform="translate(.38 52.7)"><tspan class="cls-5" x="0" y="0">Je</tspan><tspan class="cls-2" x="49.41" y="0">s</tspan><tspan class="cls-4" x="69.69" y="0">ú</tspan><tspan class="cls-2" x="96.04" y="0">s </tspan><tspan class="cls-6" x="131.5" y="0"></tspan><tspan class="cls-3" x="179.55" y="0">rez</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 987 B

View file

@ -0,0 +1,19 @@
<svg viewBox="0 0 400 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&amp;display=swap');
@keyframes fi3{0%{opacity:0;transform:translateY(10px)}100%{opacity:1;transform:translateY(0)}}
.fi3{animation:fi3 1s ease-out .5s both}
</style>
</defs>
<!-- Wordmark -->
<g class="fi3">
<text x="200" y="85" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="92" font-weight="600" letter-spacing=".03em">
<tspan fill="#C0CCD8">Onto</tspan><tspan fill="#E8A838">ref</tspan>
</text>
<text x="200" y="145" text-anchor="middle" font-family="'IBM Plex Sans',sans-serif" font-size="22" font-weight="500" fill="#A8B4C8" letter-spacing=".03em" opacity=".9">
Structure that remembers why.
</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 878 B

View file

@ -0,0 +1,197 @@
<svg viewBox="0 0 600 700" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="sG3" x1="0" y1="0" x2=".5" y2="1">
<stop offset="0%" stop-color="#E6ECF2"/>
<stop offset="50%" stop-color="#BCC8D4"/>
<stop offset="100%" stop-color="#8090A4"/>
</linearGradient>
<linearGradient id="aG3" x1=".3" y1="0" x2=".8" y2="1">
<stop offset="0%" stop-color="#B87000"/>
<stop offset="50%" stop-color="#E0B040"/>
<stop offset="100%" stop-color="#F8D860"/>
</linearGradient>
<linearGradient id="aN3" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#F5C44A"/>
<stop offset="100%" stop-color="#DC9018"/>
</linearGradient>
<linearGradient id="sN3" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#A8B4C8"/>
<stop offset="100%" stop-color="#5A6A7C"/>
</linearGradient>
<clipPath id="sC3">
<path d="M300,75 C405,195 195,345 300,465 L162,408 L105,270 L162,132 Z"/>
</clipPath>
<clipPath id="aC3">
<path d="M300,75 C405,195 195,345 300,465 L438,408 L495,270 L438,132 Z"/>
</clipPath>
<filter id="nGl3" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="b"/>
<feFlood flood-color="#E8A838" flood-opacity=".1"/>
<feComposite in2="b" operator="in" result="g"/>
<feComposite in="SourceGraphic" in2="g" operator="over"/>
</filter>
<filter id="nSh3"><feDropShadow dx="0" dy="2" stdDeviation="3.5" flood-color="#DC9018" flood-opacity=".28"/></filter>
<filter id="sGl3"><feGaussianBlur stdDeviation="1.5" result="b"/><feComposite in="SourceGraphic" in2="b" operator="over"/></filter>
<filter id="cGl3"><feGaussianBlur stdDeviation="3" result="b"/><feComposite in="SourceGraphic" in2="b" operator="over"/></filter>
</defs>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&amp;display=swap');
@keyframes bp3{0%,100%{opacity:calc(var(--o,.82) - .20)}50%{opacity:var(--o,.82)}}
@keyframes nf3{0%,100%{transform:translate(0,0)}50%{transform:translate(0,-2px)}}
@keyframes np3{0%,100%{r:var(--r,17)}50%{r:calc(var(--r,17) + 1.8)}}
@keyframes de3{0%{stroke-dashoffset:210}100%{stroke-dashoffset:0}}
@keyframes eb3{0%,100%{opacity:.48}50%{opacity:.88}}
@keyframes ap3{0%,100%{opacity:.42}50%{opacity:.92}}
@keyframes seedAmber{0%,100%{opacity:.72;r:16}50%{opacity:.88;r:18}}
@keyframes seedSilver{0%,100%{opacity:.32}50%{opacity:.52}}
@keyframes fl3{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
@keyframes cB3{0%,100%{stroke-opacity:.1}50%{stroke-opacity:.2}}
@keyframes rP3{0%,100%{stroke-opacity:.04}50%{stroke-opacity:.068}}
@keyframes tP{0%,100%{opacity:calc(var(--to,.08)*5)}50%{opacity:calc(var(--to,.08)*5 + .25)}}
@keyframes cD{0%,100%{r:2.5;opacity:.60}50%{r:3.5;opacity:.80}}
.bp3{animation:bp3 4.5s ease-in-out infinite}
.nf3{animation:nf3 5s ease-in-out infinite}
.np3{animation:np3 3.5s ease-in-out infinite}
.de3{stroke-dasharray:210;animation:de3 1.8s ease-out forwards,eb3 4s ease-in-out 2s infinite}
.ap3{animation:ap3 3s ease-in-out infinite}
.sAm{animation:seedAmber 4s ease-in-out infinite}
.sSi{animation:seedSilver 4s ease-in-out infinite}
.fl3{animation:fl3 7s ease-in-out infinite}
.cB3{animation:cB3 5s ease-in-out infinite}
.rP3{animation:rP3 7s ease-in-out infinite}
.tP{animation:tP 5.5s ease-in-out infinite}
.cD{animation:cD 4s ease-in-out infinite}
</style>
<!-- ═══ PAKUA FRAME ═══ -->
<!-- Outer octagon R=218 -->
<path d="M300,52 L454,117 L518,270 L454,423 L300,488 L146,423 L82,270 L146,117 Z"
fill="none" stroke="#E8A838" stroke-width="1.5" stroke-opacity=".35"/>
<!-- Inner decorative ring R=208 -->
<path d="M300,62 L447,124 L508,270 L447,416 L300,478 L153,416 L92,270 L153,124 Z"
fill="none" stroke="#C0CCD8" stroke-width="1.2" stroke-opacity=".30"/>
<!-- Trigram marks at 8 positions -->
<!-- Top (☰ heaven: 3 solid) — silver -->
<g class="tP" style="--to:.1">
<line x1="278" y1="50" x2="322" y2="50" stroke="#C0CCD8" stroke-width="2.2" stroke-linecap="round"/>
<line x1="281" y1="57" x2="319" y2="57" stroke="#C0CCD8" stroke-width="1.8" stroke-linecap="round"/>
<line x1="284" y1="63" x2="316" y2="63" stroke="#C0CCD8" stroke-width="1.5" stroke-linecap="round"/>
</g>
<!-- Bottom (☷ earth: 3 broken) — amber -->
<g class="tP" style="--to:.08;animation-delay:2.75s">
<line x1="280" y1="488" x2="296" y2="488" stroke="#E8A838" stroke-width="2.2" stroke-linecap="round"/>
<line x1="304" y1="488" x2="320" y2="488" stroke="#E8A838" stroke-width="2.2" stroke-linecap="round"/>
<line x1="282" y1="494" x2="295" y2="494" stroke="#E8A838" stroke-width="1.8" stroke-linecap="round"/>
<line x1="305" y1="494" x2="318" y2="494" stroke="#E8A838" stroke-width="1.8" stroke-linecap="round"/>
</g>
<!-- Right (☲ fire) — amber -->
<g class="tP" style="--to:.07;animation-delay:.7s">
<line x1="518" y1="252" x2="518" y2="266" stroke="#E8A838" stroke-width="2" stroke-linecap="round"/>
<line x1="518" y1="274" x2="518" y2="278" stroke="#E8A838" stroke-width="1.5" stroke-linecap="round"/>
<line x1="518" y1="286" x2="518" y2="300" stroke="#E8A838" stroke-width="2" stroke-linecap="round"/>
</g>
<!-- Left (☵ water) — silver -->
<g class="tP" style="--to:.07;animation-delay:2s">
<line x1="82" y1="252" x2="82" y2="260" stroke="#C0CCD8" stroke-width="2" stroke-linecap="round"/>
<line x1="82" y1="268" x2="82" y2="282" stroke="#C0CCD8" stroke-width="2" stroke-linecap="round"/>
<line x1="82" y1="290" x2="82" y2="298" stroke="#C0CCD8" stroke-width="2" stroke-linecap="round"/>
</g>
<!-- TR, TL, BR, BL — alternating -->
<g class="tP" style="--to:.06;animation-delay:1.1s">
<line x1="462" y1="110" x2="472" y2="118" stroke="#E8A838" stroke-width="2" stroke-linecap="round"/>
<line x1="458" y1="103" x2="468" y2="111" stroke="#E8A838" stroke-width="1.6" stroke-linecap="round"/>
</g>
<g class="tP" style="--to:.06;animation-delay:3.8s">
<line x1="128" y1="118" x2="138" y2="110" stroke="#C0CCD8" stroke-width="2" stroke-linecap="round"/>
<line x1="132" y1="111" x2="142" y2="103" stroke="#C0CCD8" stroke-width="1.6" stroke-linecap="round"/>
</g>
<g class="tP" style="--to:.06;animation-delay:4.4s">
<line x1="462" y1="430" x2="472" y2="422" stroke="#E8A838" stroke-width="2" stroke-linecap="round"/>
<line x1="458" y1="437" x2="468" y2="429" stroke="#E8A838" stroke-width="1.6" stroke-linecap="round"/>
</g>
<g class="tP" style="--to:.06;animation-delay:5s">
<line x1="128" y1="422" x2="138" y2="430" stroke="#C0CCD8" stroke-width="2" stroke-linecap="round"/>
<line x1="132" y1="429" x2="142" y2="437" stroke="#C0CCD8" stroke-width="1.6" stroke-linecap="round"/>
</g>
<!-- Corner dots -->
<circle class="cD" cx="300" cy="52" r="2.5" fill="#C0CCD8" style="animation-delay:0s"/>
<circle class="cD" cx="454" cy="117" r="2.5" fill="#E8A838" style="animation-delay:.55s"/>
<circle class="cD" cx="518" cy="270" r="2.5" fill="#E8A838" style="animation-delay:1.1s"/>
<circle class="cD" cx="454" cy="423" r="2.5" fill="#E8A838" style="animation-delay:1.65s"/>
<circle class="cD" cx="300" cy="488" r="2.5" fill="#E8A838" style="animation-delay:2.2s"/>
<circle class="cD" cx="146" cy="423" r="2.5" fill="#C0CCD8" style="animation-delay:2.75s"/>
<circle class="cD" cx="82" cy="270" r="2.5" fill="#C0CCD8" style="animation-delay:3.3s"/>
<circle class="cD" cx="146" cy="117" r="2.5" fill="#C0CCD8" style="animation-delay:3.85s"/>
<!-- ═══ MAIN SYMBOL ═══ -->
<g class="fl3" transform="scale(0.65) translate(64, 0)">
<!-- Octagon outline R=195 -->
<path d="M300,75 L438,132 L495,270 L438,408 L300,465 L162,408 L105,270 L162,132 Z"
fill="none" stroke="rgba(255,255,255,.055)" stroke-width="1.5"/>
<!-- SILVER REGION (Ref / Structure) -->
<g clip-path="url(#sC3)">
<path d="M300,75 L162,132 L105,270 L162,408 L300,465 C195,345 405,195 300,75Z" fill="url(#sG3)" opacity=".93"/>
<!-- Grid blocks (two columns) -->
<rect class="bp3" x="165" y="160" width="52" height="40" rx="4" fill="url(#aG3)" stroke="rgba(255,255,255,.25)" stroke-width="1" opacity=".88" style="--o:.88;animation-delay:0.9s"/>
<rect class="bp3" x="235" y="223" width="48" height="38" rx="4" fill="url(#aG3)" stroke="rgba(255,255,255,.25)" stroke-width="1" opacity=".82" style="--o:.82;animation-delay:0.7s"/>
<rect class="bp3" x="163" y="220" width="55" height="46" rx="4" fill="url(#aG3)" stroke="rgba(255,255,255,.25)" stroke-width="1" opacity=".74" style="--o:.74;animation-delay:0.5s"/>
<rect class="bp3" x="168" y="345" width="25" height="18" rx="4" fill="url(#aG3)" stroke="rgba(255,255,255,.25)" stroke-width="1" opacity=".70" style="--o:.70;animation-delay:0.15s"/>
<rect class="bp3" x="220" y="290" width="35" height="38" rx="4" fill="url(#aG3)" stroke="rgba(255,255,255,.25)" stroke-width="1" opacity=".74" style="--o:.74;animation-delay:0.3s"/>
<rect class="bp3" x="160" y="285" width="40" height="32" rx="4" fill="url(#aG3)" stroke="rgba(255,255,255,.25)" stroke-width="1" opacity=".64" style="--o:.64;animation-delay:0s"/>
<rect class="bp3" x="220" y="350" width="28" height="24" rx="4" fill="url(#aG3)" stroke="rgba(255,255,255,.25)" stroke-width="1" opacity=".64" style="--o:.64;animation-delay:.8s"/>
<!-- ★ SEED: amber circle in silver -->
<circle class="sAm" cx="265" cy="170" r="16" fill="url(#aN3)" opacity=".72" stroke="rgba(255,255,255,.3)" stroke-width="1.5"/>
</g>
<!-- AMBER REGION (Onto / Graph) -->
<g clip-path="url(#aC3)">
<path d="M300,75 L438,132 L495,270 L438,408 L300,465 C195,345 405,195 300,75Z" fill="url(#aG3)" opacity=".88"/>
<!-- DAG -->
<g filter="url(#nGl3)">
<!-- Line 1: 395,155 → 355,236 (nodo 2, r=14) termina antes del nodo -->
<line class="de3" x1="386" y1="162" x2="355" y2="214" stroke="#8090A4" stroke-width="4.5" stroke-linecap="round" opacity=".45" style="animation-delay:.15s"/>
<!-- Line 2: 395,155 → 440,236 (nodo 3, r=14) termina antes del nodo -->
<line class="de3" x1="405" y1="162" x2="440" y2="214" stroke="#8090A4" stroke-width="4.5" stroke-linecap="round" opacity=".45" style="animation-delay:.35s"/>
<!-- Line 3: 355,244 → 398,316 (nodo 4, r=15) termina antes del nodo -->
<line class="de3" x1="364" y1="247" x2="398" y2="294" stroke="#8090A4" stroke-width="4" stroke-linecap="round" opacity=".4" style="animation-delay:.6s"/>
<!-- Line 4: 440,244 → 398,316 (nodo 4, r=15) termina antes del nodo -->
<line class="de3" x1="431" y1="247" x2="398" y2="294" stroke="#8090A4" stroke-width="4" stroke-linecap="round" opacity=".4" style="animation-delay:.8s"/>
<!-- Line 5: 398,324 → 385,390 (nodo 5, r=12) termina antes del nodo -->
<line class="de3" x1="394" y1="331" x2="385" y2="368" stroke="#8090A4" stroke-width="3.5" stroke-linecap="round" opacity=".35" style="animation-delay:1.05s"/>
<!-- Arrowheads -->
<polygon class="ap3" points="349,210 357,222 363,212" fill="#8090A4" opacity=".45" style="animation-delay:.15s"/>
<polygon class="ap3" points="434,210 442,222 448,210" fill="#8090A4" opacity=".45" style="animation-delay:.35s"/>
<polygon class="ap3" points="392,290 400,302 406,290" fill="#8090A4" opacity=".4" style="animation-delay:.6s"/>
<polygon class="ap3" points="379,365 387,377 393,365" fill="#8090A4" opacity=".35" style="animation-delay:1.05s"/>
</g>
<!-- Nodes (silver gradient) -->
<g class="nf3" style="animation-delay:0s">
<circle class="np3" cx="395" cy="148" r="17" fill="url(#sN3)" stroke="rgba(255,255,255,.3)" stroke-width="1.5" opacity=".75" style="--r:17"/>
</g>
<g class="nf3" style="animation-delay:.3s">
<circle class="np3" cx="355" cy="236" r="14" fill="url(#sN3)" stroke="rgba(255,255,255,.3)" stroke-width="1.5" opacity=".7" style="--r:14"/>
</g>
<g class="nf3" style="animation-delay:.5s">
<circle class="np3" cx="440" cy="236" r="14" fill="url(#sN3)" stroke="rgba(255,255,255,.3)" stroke-width="1.5" opacity=".7" style="--r:14"/>
</g>
<g class="nf3" style="animation-delay:.8s">
<circle class="np3" cx="398" cy="316" r="15" fill="url(#sN3)" stroke="rgba(255,255,255,.3)" stroke-width="1.5" opacity=".72" style="--r:15"/>
</g>
<g class="nf3" style="animation-delay:1.1s">
<circle class="np3" cx="385" cy="390" r="12" fill="url(#sN3)" stroke="rgba(255,255,255,.3)" stroke-width="1.5" opacity=".65" style="--r:12"/>
</g>
<!-- ★ SEED: silver square in amber -->
<rect class="sAm" x="320" y="330" width="26" height="26" rx="3" fill="#5A6A7C" opacity=".85" stroke="rgba(255,255,255,.35)" stroke-width="1.5"/>
</g>
<!-- S-CURVE -->
<path class="cB3" d="M300,75 C405,195 195,345 300,465"
fill="none" stroke="rgba(255,255,255,.1)" stroke-width="1.8" filter="url(#cGl3)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,254 @@
(function () {
var deck = document.getElementById('deck-view');
if (!deck) return;
var W = 980, H = 552;
// Strings come from the markup, not from the script: one deck.js serves every language and
// stays cacheable, while the chrome around the deck speaks the page's language.
var T_SLIDE = deck.dataset.tSlide || 'Slide';
var T_ZOOM = deck.dataset.tZoom || 'Zoom';
var n = +deck.dataset.count;
var slides = [].slice.call(deck.querySelectorAll('.deck-slide'));
var count = deck.querySelector('.deck-count');
var navs = deck.querySelector('.deck-navs');
var list = deck.querySelector('.deck-toc-list');
var i = 0;
// The index is DERIVED from each slide's own H1, never authored — a hand-written TOC
// is one more surface to drift out of sync with the deck, which is the exact disease
// this projection exists to cure.
slides.forEach(function (s, k) {
s.setAttribute('data-slide-label', (k + 1) + ' / ' + n);
s.id = 'slide-' + (k + 1);
var h = s.querySelector('h1, h2, h3');
var title = (h && h.textContent.trim()) || (T_SLIDE + ' ' + (k + 1));
var z = document.createElement('button');
z.type = 'button'; z.className = 'deck-zoom'; z.dataset.zoomSlide = k;
z.setAttribute('aria-label', T_ZOOM + ' ' + (k + 1));
z.textContent = '\u2921';
s.appendChild(z);
var li = document.createElement('li');
var b = document.createElement('button');
b.type = 'button'; b.className = 'deck-toc-link'; b.dataset.goto = k;
b.innerHTML = '<span class="deck-toc-num"></span><span class="deck-toc-text"></span>';
b.querySelector('.deck-toc-num').textContent = (k + 1) + '.';
b.querySelector('.deck-toc-text').textContent = title;
li.appendChild(b); list.appendChild(li);
});
var links = [].slice.call(list.querySelectorAll('.deck-toc-link'));
// Each slide's REAL content height. Slidev's frame is 552px tall and 12 of this deck's 16
// slides hold more than that — up to 672px — so a rigid frame silently cuts three quarters
// of the deck. Slidev clips them too; nobody noticed, because until now the slides were
// PNGs of themselves. A reading surface may not lose content.
function contentHeight(slide) {
var page = slide.querySelector('.slidev-page') || slide.querySelector('.print-slide-container');
if (!page) return H;
// The container is already scaled by --deck-k, and getBoundingClientRect reports
// POST-transform coordinates. Divide it back out, or --deck-h lands in scaled pixels and
// the CSS scales it a second time.
var k = parseFloat(getComputedStyle(slide).getPropertyValue('--deck-k')) || 1;
var top = page.getBoundingClientRect().top;
var max = H, all = page.querySelectorAll('*');
for (var i = 0; i < all.length; i++) {
var bottom = (all[i].getBoundingClientRect().bottom - top) / k;
if (bottom > max) max = bottom;
}
return Math.ceil(max);
}
// Measure in whatever mode we are in. A display:none slide has no geometry, so force the
// stacked layout, measure, and restore — synchronously, so the browser never paints the
// intermediate state. Doing this by actually SWITCHING mode created a race: the markup
// ships stacked (the right no-JS fallback), the switch to slider waited on fonts, and a
// reader who pressed "Read all" inside that window was silently thrown back to slider.
function measureAll() {
var prev = deck.dataset.mode;
deck.dataset.mode = 'read';
slides.forEach(function (s) { s.style.setProperty('--deck-h', contentHeight(s)); });
deck.dataset.mode = prev;
}
// The scale CSS cannot express: box width / native width, as a plain number.
var ro = new ResizeObserver(function (entries) {
entries.forEach(function (e) {
// A display:none slide reports 0x0. Believing it writes --deck-k: 0 onto the fifteen
// hidden slides, and when the reader switches back to stacked their cards are zero-height
// until the observer fires again — asynchronously, so anything that measures in between
// sees an empty deck. An observer must not trust a measurement of an element that is not
// in the layout: keep the last good scale.
if (e.contentRect.width > 0) e.target.style.setProperty('--deck-k', e.contentRect.width / W);
});
});
slides.forEach(function (s) { ro.observe(s); });
deck.setAttribute('data-js', '');
// In slider mode the current slide is the one shown. Stacked, it is the one you are
// LOOKING at — which is not the same as the one you last clicked, so the rail follows
// the scroll rather than the click.
var visible = 0;
function mark() {
var cur = deck.dataset.mode === 'slider' ? i : visible;
links.forEach(function (l, k) { l.setAttribute('aria-current', String(k === cur)); });
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) {
var k = slides.indexOf(e.target);
if (k >= 0) { visible = k; if (deck.dataset.mode === 'read') mark(); }
}
});
}, { rootMargin: '-45% 0px -45% 0px' }); // whichever slide crosses the viewport's middle
slides.forEach(function (s) { io.observe(s); });
function show(k) {
i = (k + n) % n;
slides.forEach(function (s, j) { s.toggleAttribute('data-active', j === i); });
count.textContent = (i + 1) + ' / ' + n;
mark();
}
function setMode(m) {
deck.dataset.mode = m;
deck.querySelectorAll('[data-set-mode]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.dataset.setMode === m));
});
var slider = m === 'slider';
count.hidden = !slider; navs.hidden = !slider;
// Stacked, the rail is the navigation — a collapsed <details> above 16 slides is off
// screen by slide 5. Open it. In slider mode leave it as the reader left it.
if (slider) show(i); else mark();
}
function goto(k) {
if (deck.dataset.mode === 'slider') { show(k); deck.focus(); }
else { i = k; slides[k].scrollIntoView({ behavior: 'smooth', block: 'start' }); }
}
// The rail shows by default where there is width for a column; narrow, it starts hidden
// so it does not push the deck below the fold. Either way the reader can toggle it.
var wide = window.matchMedia('(min-width: 60rem)');
var toggle = deck.querySelector('.deck-toc-toggle');
function setToc(on) {
deck.dataset.toc = on ? 'on' : 'off';
toggle.setAttribute('aria-expanded', String(on));
}
setToc(wide.matches);
toggle.addEventListener('click', function () { setToc(deck.dataset.toc !== 'on'); });
// The sticky bar has to sit on the page's own background, whatever it is. Measure it
// rather than guess it, and re-measure when the SITE switches theme — it has its own
// toggle, and a bar frozen to the old palette is worse than no bar.
function pageBg() {
var e = deck.parentElement;
while (e) {
var bg = getComputedStyle(e).backgroundColor;
// No regex here on purpose. Inside a template literal every backslash is eaten once
// before the regex engine sees it, and /^rgba(0,0,0,0)$/ silently became a capture
// group that matched nothing — syntactically valid, semantically wrong, invisible to
// a syntax check. String comparison needs no escapes and cannot be mis-escaped.
if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') return bg;
e = e.parentElement;
}
return getComputedStyle(document.body).backgroundColor;
}
function syncPageBg() { deck.style.setProperty('--deck-page-bg', pageBg()); }
syncPageBg();
new MutationObserver(syncPageBg).observe(document.documentElement, {
attributes: true, attributeFilter: ['class', 'data-theme'],
});
var lastFocus = null;
function zoomOpen(k) {
lastFocus = document.activeElement;
show(k);
deck.setAttribute('data-zoom', '');
document.body.style.overflow = 'hidden';
deck.focus();
}
function zoomClose() {
if (!deck.hasAttribute('data-zoom')) return;
deck.removeAttribute('data-zoom');
document.body.style.overflow = '';
if (lastFocus && lastFocus.isConnected) lastFocus.focus();
}
// Slidev's copy buttons are in the captured DOM but inert — their handler was Vue.
// Rewire them. innerText, not textContent: it is the RENDERED code, so Shiki's per-line
// spans come back as actual newlines instead of one run-on string.
function copyCode(btn) {
var wrap = btn.closest('.slidev-code-wrapper');
var pre = wrap && wrap.querySelector('pre');
if (!pre) return;
var text = pre.innerText.replace(/\n$/, '');
var done = function () {
wrap.classList.add('deck-copied');
setTimeout(function () { wrap.classList.remove('deck-copied'); }, 1200);
};
function legacy() {
var ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } finally { ta.remove(); }
}
// The Clipboard API REJECTS when the document is not focused — a real case, not a
// theoretical one. Without the catch, the copy fails in complete silence.
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done, legacy);
} else {
legacy();
}
}
deck.addEventListener('click', function (e) {
var cp = e.target.closest('.slidev-code-copy');
if (cp) { e.preventDefault(); return copyCode(cp); }
if (e.target.closest('.deck-backdrop')) return zoomClose();
// data-zoom-slide (the button) is deliberately NOT data-zoom (the deck's open state).
var z = e.target.closest('[data-zoom-slide]');
if (z) return deck.hasAttribute('data-zoom') ? zoomClose() : zoomOpen(+z.dataset.zoomSlide);
var m = e.target.closest('[data-set-mode]');
if (m) return setMode(m.dataset.setMode);
var t = e.target.closest('[data-goto]');
if (t) return goto(+t.dataset.goto);
var nav = e.target.closest('.deck-nav');
if (nav) show(i + (+nav.dataset.dir));
});
// Keys are scoped to the deck holding focus. A document-wide Space handler would steal
// page scrolling from every reader who never asked the deck for anything.
deck.addEventListener('keydown', function (e) {
var zoomed = deck.hasAttribute('data-zoom');
if (zoomed && e.key === 'Escape') { e.preventDefault(); return zoomClose(); }
// Zoomed, the keys must drive even stacked: a lightbox you can leave but not walk is
// a dead end.
if (!zoomed && deck.dataset.mode !== 'slider') return;
if (e.target.closest('.deck-toc')) return;
var fwd = e.key === 'ArrowRight' || e.key === 'ArrowDown' || (e.key === ' ' && !e.shiftKey);
var back = e.key === 'ArrowLeft' || e.key === 'ArrowUp' || (e.key === ' ' && e.shiftKey);
if (fwd) { e.preventDefault(); show(i + 1); }
else if (back) { e.preventDefault(); show(i - 1); }
else if (e.key === 'Home') { e.preventDefault(); show(0); }
else if (e.key === 'End') { e.preventDefault(); show(n - 1); }
});
// Printing in slider mode would emit exactly one slide.
window.addEventListener('beforeprint', function () { setMode('read'); });
// Measure AFTER the webfonts land. With the fallback font the text is shorter and every
// slide "fits"; once Nunito Sans arrives it grows and spills. Measured before fonts, every
// --deck-h came back 552 and nine slides were still cut.
// And measure while still stacked: a display:none slide has no geometry at all. So the
// switch to slider waits for the measurement, not the other way round.
var hash = (location.hash.match(/^#slide-(\d+)$/) || [])[1];
// Take the mode immediately — no waiting, no race with the reader.
measureAll();
setMode('slider');
if (hash) show(+hash - 1);
// Then measure again once the webfonts land: with the fallback face the text is shorter
// and every slide "fits", so a pre-font measurement returns 552 for all of them and nine
// slides stay cut. This pass changes no mode and interrupts nothing.
if (document.fonts && document.fonts.ready) document.fonts.ready.then(measureAll);
window.addEventListener('resize', measureAll);
})();