From da93544de3cc3d63f77b4552e94fbf8518e63000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesu=CC=81s=20Pe=CC=81rez?= Date: Tue, 14 Jul 2026 19:41:51 +0100 Subject: [PATCH] =?UTF-8?q?decks:=20the=20slides=20stop=20being=20pixels?= =?UTF-8?q?=20=E2=80=94=20Slidev's=20own=20render,=20captured=20and=20re-h?= =?UTF-8?q?omed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ${withDeck ? `` : ''} + +

Título del sitio

+

Texto con NCL y enlace.

+ + ${DECK} +

Después del deck.

+ + +` + +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) diff --git a/scripts/decks/gates/served.mjs b/scripts/decks/gates/served.mjs new file mode 100644 index 0000000..bea71fb --- /dev/null +++ b/scripts/decks/gates/served.mjs @@ -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 +import { chromium } 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