diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4470d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ + +.coder/ +.claude/ +.internal.git/ + +presentation/node_modules/ +presentation/dist/ +presentation/dist-prerender/ + +*.swp diff --git a/presentation/package.json b/presentation/package.json new file mode 100644 index 0000000..1791da7 --- /dev/null +++ b/presentation/package.json @@ -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" + } +} diff --git a/scripts/decks/decks.ncl b/scripts/decks/decks.ncl new file mode 100644 index 0000000..28315cd --- /dev/null +++ b/scripts/decks/decks.ncl @@ -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.", + }, + }, +} diff --git a/scripts/decks/gates/artifact.mjs b/scripts/decks/gates/artifact.mjs new file mode 100644 index 0000000..9e8ba75 --- /dev/null +++ b/scripts/decks/gates/artifact.mjs @@ -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 +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 '); 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 = `
+
+
` + +const page = (withDeck) => ` +${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