diff --git a/package.json b/package.json index 721dc3f..90493b6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "presentations:build": "node scripts/build-presentations.mjs", "serve": "docusaurus serve", "test:presentation": "node scripts/browser/presentation.cjs", + "test:homepage-embed": "node scripts/browser/homepage-embed.cjs", "typecheck": "tsc --noEmit", "verify": "pnpm test && node scripts/verify.mjs", "verify:build": "node scripts/verify.mjs --build", diff --git a/presentations/less-prompting-more-shipping/runtime.js b/presentations/less-prompting-more-shipping/runtime.js index b28f26f..fadf6b6 100644 --- a/presentations/less-prompting-more-shipping/runtime.js +++ b/presentations/less-prompting-more-shipping/runtime.js @@ -2,6 +2,13 @@ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); const portrait = window.matchMedia('(max-width: 700px) and (orientation: portrait)'); const printing = new URLSearchParams(location.search).has('print-pdf'); +const embedded = new URLSearchParams(location.search).has('embed') && !printing; +document.documentElement.classList.toggle('embedded', embedded); +if (embedded) { + document.querySelector('.toolbar').hidden = true; + // Never load the portal inside the stage when following the reference appendix. + document.querySelectorAll('.slides a').forEach(link => { link.target = '_top'; }); +} function layoutSize(){ const narrow=portrait.matches&&!printing; document.documentElement.classList.toggle('portrait',narrow); @@ -9,10 +16,13 @@ function layoutSize(){ } Reveal.initialize({ ...layoutSize(), margin:0.04, minScale:0.1, maxScale:2, - hash:true, hashOneBasedIndex:false, controls:true, progress:true, + hash:!embedded, embedded, keyboardCondition:embedded ? () => document.hasFocus() : null, + hashOneBasedIndex:false, controls:true, progress:true, slideNumber:'c/t', showSlideNumber:'all', center:false, transition:reducedMotion.matches?'none':'fade', transitionSpeed:'fast', - backgroundTransition:'none', autoSlide:0, touch:true, keyboard:true, + backgroundTransition:'none', autoSlide:0, touch:true, + // The host owns fullscreen; speaker tools remain available in the standalone deck. + keyboard:embedded ? {70:null,83:null} : true, // Keep deliberate slides on phones rather than Reveal's automatic scroll view. scrollActivationWidth:null, pdfMaxPagesPerSlide:1, pdfSeparateFragments:false, plugins:[RevealNotes] diff --git a/presentations/less-prompting-more-shipping/theme.css b/presentations/less-prompting-more-shipping/theme.css index f59fb59..7c3a00d 100644 --- a/presentations/less-prompting-more-shipping/theme.css +++ b/presentations/less-prompting-more-shipping/theme.css @@ -55,5 +55,12 @@ a:focus-visible,button:focus-visible{outline:3px solid #b88719;outline-offset:3p .portrait #qualification .contrast{grid-template-columns:1fr 1fr;gap:24px}.portrait #qualification .contrast p{font-size:24px} .portrait .deck .references{font-size:29px}.portrait .deck .references li{margin:22px 0} @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}} +.embedded .toolbar{display:none} +.embedded .reveal.deck{top:0;height:calc(100dvh - 48px)} +.embedded body{background:#101014} +.embedded .deck .controls{position:fixed;bottom:8px;color:var(--gold)!important} +.embedded .deck .progress{color:var(--gold)} +.embedded .deck .slide-number{position:fixed;bottom:12px} +.embedded .deck .progress{position:fixed;bottom:0} @media print{.toolbar{display:none}.reveal.deck{top:0;height:100%}} html.print-pdf .toolbar{display:none}html.print-pdf .reveal.deck{top:0;height:100%} diff --git a/scripts/browser/homepage-embed.cjs b/scripts/browser/homepage-embed.cjs new file mode 100644 index 0000000..d160e1b --- /dev/null +++ b/scripts/browser/homepage-embed.cjs @@ -0,0 +1,57 @@ +const {chromium}=require('playwright'); +const assert=require('node:assert/strict');const fs=require('node:fs'); +(async()=>{ + const base=process.argv[2]||'http://127.0.0.1:43853';const root=base+'/eldorado-v1/documentation/'; + const out='.cache/homepage-embed';fs.mkdirSync(out,{recursive:true}); + const browser=await chromium.launch({headless:true,executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,args:['--no-sandbox']});const results=[]; + try{ + for(const viewport of [{width:1440,height:900},{width:390,height:844},{width:320,height:720},{width:844,height:390}]){ + const context=await browser.newContext({viewport,hasTouch:true,reducedMotion:'reduce'});const p=await context.newPage();const errors=[]; + p.on('pageerror',e=>errors.push(e.message));p.on('response',r=>{if(r.status()>=400)errors.push(`${r.status()} ${r.url()}`)}); + await p.goto(root,{waitUntil:'networkidle'});const section=p.locator('section[aria-labelledby="presentation-title"]');await section.scrollIntoViewIfNeeded(); + const iframe=p.locator('iframe[title="Less Prompting, More Shipping — interactive presentation"]');const f=await iframe.elementHandle().then(e=>e.contentFrame());await f.waitForFunction(()=>window.Reveal?.isReady());await f.evaluate(()=>document.fonts.ready); + assert.deepEqual(await p.locator('#start-title,#presentation-title').evaluateAll(es=>es.map(e=>{const s=getComputedStyle(e);return [s.fontFamily,s.fontSize,s.fontWeight,s.letterSpacing,s.lineHeight]})).then(a=>a[0]),await p.locator('#presentation-title').evaluate(e=>{const s=getComputedStyle(e);return [s.fontFamily,s.fontSize,s.fontWeight,s.letterSpacing,s.lineHeight]})); + assert.equal(await f.locator('.toolbar').isVisible(),false);assert.equal(await f.evaluate(()=>Reveal.getConfig().hash),false);assert.equal(await f.evaluate(()=>Reveal.getConfig().transition),'none');assert.equal(await f.evaluate(()=>Reveal.getTotalSlides()),19); + assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); + if(viewport.width<=390)assert(await iframe.evaluate(e=>e.clientHeight>e.clientWidth),'phone stage must recompose rather than shrink landscape text'); + await p.getByRole('button',{name:'Fullscreen',exact:true}).focus();await p.keyboard.press('ArrowRight');assert.equal(await f.evaluate(()=>Reveal.getIndices().h),0); + await f.locator('.controls .navigate-right').click();assert.equal(await f.evaluate(()=>Reveal.getIndices().h),1); + const scroll=await p.evaluate(()=>scrollY);await p.keyboard.press('ArrowRight');assert.equal(await f.evaluate(()=>Reveal.getIndices().h),2);assert.equal(await p.evaluate(()=>scrollY),scroll);assert.equal(p.url(),root); + await f.evaluate(()=>Reveal.slide(0)); + const visited=[]; + for(let i=0;i<19;i++){ + assert.equal(await f.evaluate(()=>Reveal.getIndices().h),i);visited.push(await f.evaluate(()=>Reveal.getCurrentSlide().id)); + const bad=await f.evaluate(()=>{const r=Reveal.getCurrentSlide().getBoundingClientRect();return [...Reveal.getCurrentSlide().querySelectorAll('h1,h2,p,li,table,.flow,.roles,.timeline')].filter(e=>!e.closest('.notes')).filter(e=>{const b=e.getBoundingClientRect();return b.bottom>r.bottom+1||b.right>r.right+1||b.lefte.textContent)});assert.deepEqual(bad,[]); + await iframe.screenshot({path:`${out}/${viewport.width}-${i+1}.png`}); + if(i<18)await p.keyboard.press('ArrowRight'); + } + assert.equal(new Set(visited).size,19);assert.equal(p.url(),root);assert.equal(new URL(f.url()).hash,''); + await f.evaluate(()=>Reveal.slide(0));await section.screenshot({path:`${out}/${viewport.width}-light.png`}); + await p.evaluate(()=>document.documentElement.setAttribute('data-theme','dark'));await section.screenshot({path:`${out}/${viewport.width}-dark.png`}); + await p.getByRole('button',{name:'Fullscreen',exact:true}).click();await p.waitForFunction(()=>!!document.fullscreenElement);await f.waitForTimeout(100); + await p.screenshot({path:`${out}/${viewport.width}-fullscreen.png`}); + await p.getByRole('button',{name:'Exit fullscreen',exact:true}).click();await p.waitForFunction(()=>!document.fullscreenElement);await p.waitForFunction(()=>document.activeElement?.textContent==='Fullscreen'); + // Native touch swipe inside the stage, not a programmatic Reveal.next(). + await iframe.scrollIntoViewIfNeeded();const b=await iframe.boundingBox();const cdp=await context.newCDPSession(p); + await cdp.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:b.x+b.width*.8,y:b.y+b.height*.4}]}); + await cdp.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x:b.x+b.width*.2,y:b.y+b.height*.4}]});await cdp.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});assert.equal(await f.evaluate(()=>Reveal.getIndices().h),1); + await p.emulateMedia({reducedMotion:'no-preference'});await f.waitForFunction(()=>Reveal.getConfig().transition==='fade');await p.emulateMedia({reducedMotion:'reduce'});await f.waitForFunction(()=>Reveal.getConfig().transition==='none'); + await f.locator('.navigate-right').focus();await p.keyboard.press('f');await p.keyboard.press('s');assert.equal(context.pages().length,1);assert.equal(await p.evaluate(()=>!!document.fullscreenElement),false); + if(viewport.width<=390){ + await p.setViewportSize({width:844,height:390});await f.waitForFunction(()=>Reveal.getConfig().width===1200);assert.equal(await f.evaluate(()=>Reveal.getIndices().h),1); + await p.setViewportSize(viewport);await f.waitForFunction(()=>Reveal.getConfig().width===600); + } + // Unsupported fullscreen is non-destructive and keeps a working standalone escape. + await p.evaluate(()=>{window.savedFullscreen=Element.prototype.requestFullscreen;Element.prototype.requestFullscreen=undefined}); + await p.getByRole('button',{name:'Fullscreen',exact:true}).click();await p.getByRole('status').filter({hasText:'Fullscreen is unavailable'}).waitFor();assert.equal(p.url(),root); + await p.evaluate(()=>{Element.prototype.requestFullscreen=window.savedFullscreen}); + // No keyboard trap: browser Tab can leave the frame for the standalone affordance. + await f.locator('.navigate-right').focus();for(let i=0;i<12&&!(await p.getByRole('link',{name:/Open presentation/}).evaluate(e=>e===document.activeElement));i++)await p.keyboard.press('Tab'); + assert(await p.getByRole('link',{name:/Open presentation/}).evaluate(e=>e===document.activeElement)); + for(const href of await p.locator('.start-card').evaluateAll(es=>es.map(e=>e.href)))assert.equal((await p.request.get(href)).status(),200); + await p.getByRole('link',{name:/Open presentation/}).click();await p.waitForFunction(()=>window.Reveal?.isReady());assert(!p.url().includes('embed'));assert(await p.locator('.toolbar').isVisible()); + assert.deepEqual(errors,[]);results.push({viewport,visited,headingParity:true,keyboardIsolation:true,scrollIsolation:true,touch:true,fullscreen:true,tabExit:true,errors});await context.close(); + } + fs.writeFileSync(`${out}/results.json`,JSON.stringify(results,null,2));console.log('PASS: 76 inline slides, heading parity, URLs, focus/scroll isolation, touch, fullscreen, Tab exit, themes, reduced motion and standalone handoff'); + }finally{await browser.close()} +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/scripts/homepage-embed.test.mjs b/scripts/homepage-embed.test.mjs new file mode 100644 index 0000000..f6d3a15 --- /dev/null +++ b/scripts/homepage-embed.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import vm from 'node:vm'; +function runtime(search) { + let config; const classes=new Set(); const links=[{}]; + const document={documentElement:{classList:{toggle:(name,on)=>on?classes.add(name):classes.delete(name)}},getElementById:()=>({addEventListener(){},hidden:false}),querySelector:()=>({hidden:false}),querySelectorAll:()=>links,hasFocus:()=>true}; + const window={matchMedia:()=>({matches:false,addEventListener(){}})}; + vm.runInNewContext(read('presentations/less-prompting-more-shipping/runtime.js'),{window,document,location:{search},URLSearchParams,RevealNotes:{},Reveal:{initialize:c=>{config=c;},configure(){}}}); + return {config,classes,links}; +} +test('embed isolates URL, focus and deck links without autoplay; print stays standalone',()=>{ + const embed=runtime('?embed'); + assert.equal(embed.config.hash,false); + assert.equal(embed.config.embedded,true); + assert.equal(embed.config.autoSlide,0); + assert.equal(embed.config.keyboard[70],null); + assert.equal(embed.config.keyboard[83],null); + assert.equal(typeof embed.config.keyboardCondition,'function'); + assert(embed.classes.has('embedded')); + assert.equal(embed.links[0].target,'_top'); + for(const query of ['', '?print-pdf', '?embed&print-pdf']) { + const {config,classes}=runtime(query); + assert.equal(config.hash,true);assert(!classes.has('embedded')); + } +}); +const read=p=>readFileSync(p,'utf8'); +test('homepage replaces teaser with titled single-source playable stage below existing cards',()=>{ + const home=read('src/pages/index.tsx'); + assert.match(home,/

Understand In Speed<\/h2>/); + assert(home.indexOf('Understand In Speed')>home.indexOf('Shaping the direction.')); + assert.match(home,/(null); + const button = useRef(null); + const [fullscreen, setFullscreen] = useState(false); + const [error, setError] = useState(''); + useEffect(() => { + const changed = () => { + const active = document.fullscreenElement === stage.current; + setFullscreen(active); + if (!active) button.current?.focus({preventScroll: true}); + }; + document.addEventListener('fullscreenchange', changed); + return () => document.removeEventListener('fullscreenchange', changed); + }, []); + async function toggleFullscreen() { + setError(''); + try { + if (document.fullscreenElement === stage.current) await document.exitFullscreen(); + else if (stage.current?.requestFullscreen) await stage.current.requestFullscreen(); + else setError('Fullscreen is unavailable. Open presentation for a larger view.'); + } catch { + setError('Fullscreen is unavailable. Open presentation for a larger view.'); + } + } + return
+