ontoref-outreach/scripts/decks/gates/artifact.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

130 lines
6.1 KiB
JavaScript

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