// 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. // M ANCHORS every `gloss_href` the term registry points AT THIS PAGE lands on the // slide it names — measured by LOADING the URL and reading which slide // came up, never by checking that an id exists in the DOM. // // Usage: node gates/served.mjs import { chromium } from '../lib/paths.mjs' import { execFileSync } from 'node:child_process' import { join } from 'node:path' import { OUTREACH } from '../lib/paths.mjs' const URL_ = process.argv[2] if (!URL_) { console.error('uso: node gates/served.mjs '); 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