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

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

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

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

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

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

296 lines
16 KiB
JavaScript

// 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)