tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

theme.mjs (8613B)


      1 // Presentation-only browser regression; no compiler/runtime assets required.
      2 // nix shell nixpkgs#nodejs nixpkgs#chromium -c node ext/demo-site/test/theme.mjs
      3 import assert from 'node:assert/strict';
      4 import {spawn} from 'node:child_process';
      5 import {mkdtemp,readFile,writeFile,copyFile,rm} from 'node:fs/promises';
      6 import {tmpdir} from 'node:os';
      7 import {join} from 'node:path';
      8 import {pathToFileURL} from 'node:url';
      9 const dir=await mkdtemp(join(tmpdir(),'arboricx-theme-'));
     10 let browser,socket;
     11 try{
     12  const root=new URL('../',import.meta.url);
     13  const html=(await readFile(new URL('index.html',root),'utf8')).replace(/<script type="module"[^>]*><\/script>/,'');
     14  await writeFile(join(dir,'index.html'),html);
     15  await copyFile(new URL('style.css',root),join(dir,'style.css'));
     16  await copyFile(new URL('theme.js',root),join(dir,'theme.js'));
     17  browser=spawn(process.argv[2]||'chromium',['--headless','--no-sandbox','--disable-dev-shm-usage','--remote-debugging-port=0',`--user-data-dir=${join(dir,'profile')}`,'about:blank'],{stdio:['ignore','ignore','pipe']});
     18  const endpoint=await new Promise((resolve,reject)=>{
     19   let log='';const timer=setTimeout(()=>reject(Error(log)),15000);
     20   browser.on('error',reject);
     21   browser.stderr.on('data',chunk=>{log+=chunk;const m=log.match(/DevTools listening on (ws:\/\/\S+)/);if(m){clearTimeout(timer);resolve(new URL(m[1]));}});
     22  });
     23  const tab=await(await fetch(`http://${endpoint.host}/json/new?about:blank`,{method:'PUT'})).json();
     24  socket=new WebSocket(tab.webSocketDebuggerUrl);
     25  await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;});
     26  let id=0;const pending=new Map();
     27  socket.onmessage=({data})=>{const event=JSON.parse(data);if(event.id){const p=pending.get(event.id);pending.delete(event.id);event.error?p.reject(Error(JSON.stringify(event.error))):p.resolve(event.result);}};
     28  const send=(method,params={})=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});socket.send(JSON.stringify({id,method,params}));});
     29  const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;};
     30  await send('Page.navigate',{url:pathToFileURL(join(dir,'index.html')).href});
     31  for(let i=0;!await evaluate("document.readyState==='complete' && !!document.getElementById('source-editor')");i++){assert.ok(i<200);await new Promise(r=>setTimeout(r,25));}
     32  // Install the same presentation wrappers used by syntax.ts, without loading a runtime.
     33  await evaluate(`(()=>{for(const id of ['source-editor']){const input=document.getElementById(id),wrapper=document.createElement('div');wrapper.className='syntax-editor';input.before(wrapper);wrapper.append(input);input.classList.add('syntax-input');}})()`);
     34  await evaluate(`(()=>{const card=document.createElement('section');card.className='definition-card';card.style.position='relative';card.innerHTML='<div class="inline-io"><details><summary>IO details</summary></details><button>Run IO</button><input aria-label="Line"><select><option>Text</option></select></div>';document.querySelector('.source-panel').append(card);})()`);
     35  for(const scheme of ['light','dark','light']){
     36   await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-color-scheme',value:scheme}]});
     37   await evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))');
     38   const result=await evaluate(`(()=>{
     39    const css=e=>getComputedStyle(e),body=css(document.body),root=css(document.documentElement);
     40    const rgb=s=>s.match(/[\\d.]+/g).slice(0,3).map(Number);
     41    const lum=s=>rgb(s).map(v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4;}).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0);
     42    const contrast=(a,b)=>(Math.max(lum(a),lum(b))+.05)/(Math.min(lum(a),lum(b))+.05);
     43    const background=element=>{for(let node=element;node;node=node.parentElement){const color=css(node).backgroundColor;if(color!=='rgba(0, 0, 0, 0)')return color;}return body.backgroundColor;};
     44    const pairs=[[body.color,body.backgroundColor]];
     45    for(const selector of ['.intro','a','.button.primary','#source-editor','#settings-panel','#import-dialog','.definition-card summary','.definition-card .inline-io button','.definition-card .inline-io input']){
     46     const e=document.querySelector(selector),s=css(e);
     47     pairs.push([selector==='#source-editor'?s.caretColor:s.color,background(e)]);
     48    }
     49    const editor=document.getElementById('source-editor').parentElement,ec=css(editor);
     50    for(const token of ['comment','string','escape','number','keyword','definition','tree','contract','invalid','operator']){
     51     const span=document.createElement('span');span.className='syntax-'+token;editor.append(span);pairs.push([css(span).color,ec.backgroundColor]);span.remove();
     52    }
     53    return {cardScheme:css(document.querySelector('.definition-card select')).colorScheme,scheme:root.colorScheme,paper:body.backgroundColor,contrast:pairs.map(([a,b])=>contrast(a,b)),source:css(document.getElementById('source-editor')).caretColor,
     54     chrome:[...document.querySelectorAll('meta[name="theme-color"]')].filter(m=>matchMedia(m.media).matches).map(m=>m.content)};
     55   })()`);
     56   assert.equal(result.scheme,scheme);assert.equal(result.cardScheme,'dark','Dark card native controls remain legible on a light page');
     57   assert.equal(result.paper,scheme==='dark'?'rgb(20, 30, 26)':'rgb(245, 242, 234)');
     58   assert.deepEqual(result.chrome,[scheme==='dark'?'#141e1a':'#f5f2ea']);
     59   assert.equal(result.source,'rgb(227, 237, 222)');
     60   for(const ratio of result.contrast)assert.ok(ratio>=4.5,`${scheme} text contrast ${ratio}`);
     61  }
     62  const scheme=()=>evaluate('getComputedStyle(document.documentElement).colorScheme');
     63  await evaluate("document.getElementById('theme-toggle').click()");
     64  assert.equal(await scheme(),'dark','Toggle overrides light system preference: '+JSON.stringify(await evaluate("({theme:document.documentElement.dataset.theme,hidden:document.getElementById('theme-toggle').hidden,aria:document.getElementById('theme-toggle').getAttribute('aria-label'),scripts:[...document.scripts].map(s=>s.src),href:location.href})")));
     65  assert.equal(await evaluate("document.getElementById('theme-toggle').getAttribute('aria-label')"),'Switch to light mode');
     66  await send('Page.reload');
     67  for(let i=0;!await evaluate("document.readyState==='complete' && !document.getElementById('theme-toggle')?.hidden");i++){assert.ok(i<200);await new Promise(r=>setTimeout(r,25));}
     68  assert.equal(await scheme(),'dark','Explicit choice survives reload');
     69  await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-color-scheme',value:'dark'}]});
     70  await evaluate("document.getElementById('theme-toggle').focus()");
     71  await send('Input.dispatchKeyEvent',{type:'keyDown',key:' ',code:'Space',windowsVirtualKeyCode:32});
     72  await send('Input.dispatchKeyEvent',{type:'keyUp',key:' ',code:'Space',windowsVirtualKeyCode:32});
     73  assert.equal(await scheme(),'light','Keyboard toggles to light even on a dark system');
     74  for(const width of [320,390,1440]){
     75   await send('Emulation.setDeviceMetricsOverride',{width,height:844,deviceScaleFactor:1,mobile:width<950});
     76   assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,'Header fits '+width);
     77   assert.equal(await evaluate("document.getElementById('theme-toggle').getBoundingClientRect().height>=44"),true,'Touch target');
     78  }
     79  await evaluate("localStorage.removeItem('arboricx-theme');window.dispatchEvent(new StorageEvent('storage',{key:'arboricx-theme',newValue:null}))");
     80  assert.equal(await scheme(),'dark','Clearing preference resumes system default');
     81  // Storage failure must not prevent page-local toggling.
     82  await evaluate("Storage.prototype.setItem=()=>{throw Error('blocked')};document.getElementById('theme-toggle').click()");
     83  assert.equal(await scheme(),'light');
     84  await evaluate("document.getElementById('source-editor').classList.add('syntax-input')");
     85  await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-color-scheme',value:'dark'},{name:'forced-colors',value:'active'}]});
     86  assert.equal(await evaluate("getComputedStyle(document.getElementById('source-editor')).color===getComputedStyle(document.getElementById('source-editor')).caretColor"),true);
     87  console.log('Theme checks passed: system changes, contrast, toggle/keyboard, persistence, mobile header, blocked storage and forced colors.');
     88 }finally{
     89  socket?.close();
     90  if(browser&&browser.exitCode===null){const exited=new Promise(r=>browser.once('exit',r));browser.kill();await exited;}
     91  await rm(dir,{recursive:true,force:true});
     92 }