Measure keyboard continuation and navigation feedback
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
This commit is contained in:
parent
2b1c9a4109
commit
26d7c4523b
17 changed files with 1773 additions and 13 deletions
|
|
@ -38,3 +38,18 @@ History eviction uses htmx 2.0.11's sessionStorage key, an experimental control
|
|||
to the pinned implementation. No assertion of identical content freshness across
|
||||
history, general offline availability, accessibility, script-late races, writes,
|
||||
real intermediary correctness or independent full-pattern recurrence follows.
|
||||
|
||||
## Keyboard/focus follow-up
|
||||
|
||||
[Preregistered LEARN-0012](../../evolution/cycles/2026-09-27-navigation-focus.md),
|
||||
[review](../../reviews/NavigationFocusExperiment.md), [receipt](focus-result.json).
|
||||
Run the same browser environment with `node focus.mjs`. Output defaults to
|
||||
`/tmp/fep-focus-result.json`; choose a fresh `FEP_RESULT_PATH` per execution.
|
||||
The separate focus runner/policy preserve the earlier navigation/cache inputs and
|
||||
receipt hashes. Same locked packages. 22 cases: native keyboard plus raw/managed
|
||||
navigation, 503 feedback, held-response focus ownership, history and background work.
|
||||
|
||||
Raw rows observe behavior, so 22 passing rows do not mean raw and managed variants
|
||||
meet identical focus/feedback obligations. See NF01–NF06. The policy is authored
|
||||
experimental code; DOM focus and live-region text do not establish assistive-technology
|
||||
announcements or conformance. History and request-race limits remain explicit.
|
||||
|
|
|
|||
32
research/experiments/navigation-history/focus-policy.js
Normal file
32
research/experiments/navigation-history/focus-policy.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Authored experimental navigation policy; raw variant installs only diagnostics.
|
||||
window.probe=[];
|
||||
const managed=new URL(location.href).searchParams.get('mode')==='managed';
|
||||
const pending=new WeakMap();
|
||||
const notice=message=>{document.querySelector('#feedback').textContent=message;};
|
||||
function complete(moveFocus){
|
||||
const heading=document.querySelector('#heading');
|
||||
document.title=`${heading.textContent} — Catalog`;
|
||||
notice(`Loaded ${heading.textContent}`);
|
||||
if(moveFocus)heading.focus();
|
||||
}
|
||||
document.addEventListener('htmx:beforeRequest',e=>{
|
||||
if(managed&&e.detail.target.id==='content')pending.set(e.detail.xhr,{source:e.detail.elt,ownsFocus:false});
|
||||
});
|
||||
document.addEventListener('htmx:beforeSwap',e=>{
|
||||
const state=pending.get(e.detail.xhr);
|
||||
if(state)state.ownsFocus=document.activeElement===state.source;
|
||||
});
|
||||
document.addEventListener('htmx:afterSwap',e=>{
|
||||
const state=pending.get(e.detail.xhr);
|
||||
if(state){complete(state.ownsFocus);pending.delete(e.detail.xhr);}
|
||||
});
|
||||
document.addEventListener('htmx:responseError',e=>{
|
||||
if(managed&&pending.has(e.detail.xhr)){
|
||||
notice('Navigation failed. Current article retained. Use Native recovery to continue.');
|
||||
pending.delete(e.detail.xhr);
|
||||
}
|
||||
});
|
||||
document.addEventListener('htmx:historyRestore',()=>{if(managed)complete(true);});
|
||||
for(const name of ['htmx:afterSettle','htmx:historyRestore','htmx:responseError']){
|
||||
document.addEventListener(name,()=>window.probe.push({name,focus:document.activeElement.id||document.activeElement.tagName,title:document.title,url:location.href,feedback:document.querySelector('#feedback').textContent}));
|
||||
}
|
||||
1354
research/experiments/navigation-history/focus-result.json
Normal file
1354
research/experiments/navigation-history/focus-result.json
Normal file
File diff suppressed because it is too large
Load diff
74
research/experiments/navigation-history/focus.mjs
Normal file
74
research/experiments/navigation-history/focus.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import {chromium,firefox} from 'playwright';
|
||||
import {createServer} from 'node:http';
|
||||
import {once} from 'node:events';
|
||||
import {readFile,writeFile} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
const dir=new URL('.',import.meta.url);
|
||||
const htmx=await readFile(new URL('node_modules/htmx.org/dist/htmx.js',dir));
|
||||
const policy=await readFile(new URL('focus-policy.js',dir));
|
||||
let requests=[],release=null,heldResolve=null;
|
||||
const server=createServer((req,res)=>{
|
||||
const u=new URL(req.url,'http://local');res.setHeader('Cache-Control','no-store');
|
||||
const hx=req.headers['hx-request']==='true';requests.push({path:req.url,hx,restore:req.headers['hx-history-restore-request']==='true'});
|
||||
if(u.pathname==='/htmx.js'||u.pathname==='/policy.js'){res.setHeader('Content-Type','application/javascript');res.end(u.pathname==='/htmx.js'?htmx:policy);return;}
|
||||
if(u.pathname==='/news'){res.end('<p>Background news updated</p>');return;}
|
||||
if(u.pathname!=='/articles'){res.writeHead(404);res.end('Not found');return;}
|
||||
const topic=u.searchParams.get('topic')==='beta'?'beta':'alpha',mode=u.searchParams.get('mode')==='managed'?'managed':'raw';
|
||||
const target=`/articles?topic=${topic==='alpha'?'beta':'alpha'}&mode=${mode}`;
|
||||
const boost='hx-boost="true" hx-target="#content" hx-select="#content" hx-swap="outerHTML"';
|
||||
const content=`<main id="content" hx-history-elt><h1 id="heading" tabindex="-1">${topic}</h1><p>Article ${topic}</p><a id="next" href="${target}" ${boost}>Other topic</a><a id="broken" href="${target}&fail=1" ${boost}>Fail enhanced navigation</a><a id="recover" href="${target}&fail=1">Native recovery</a><a id="slow" href="${target}&slow=1" ${boost}>Delayed navigation</a></main>`;
|
||||
const body=hx?content:`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${topic} — Catalog</title><meta name="htmx-config" content='{"historyRestoreAsHxRequest":false}'><script src="/policy.js" defer></script><script src="/htmx.js" defer></script></head><body>${content}<label>Persistent search <input id="search"></label><p id="feedback" role="status" aria-live="polite" aria-atomic="true"></p><aside id="news">Initial news</aside></body></html>`;
|
||||
const send=()=>{res.writeHead(hx&&u.searchParams.has('fail')?503:200,{'Content-Type':'text/html'});res.end(hx&&u.searchParams.has('fail')?'Outage':body);};
|
||||
if(hx&&u.searchParams.has('slow')){release=send;heldResolve?.();}else send();
|
||||
});
|
||||
server.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`;
|
||||
const results=[],browsers={};const output=process.env.FEP_RESULT_PATH||'/tmp/fep-focus-result.json';
|
||||
async function inspect(p){return p.evaluate(()=>({focus:document.activeElement.id||document.activeElement.tagName,title:document.title,url:location.href,heading:document.querySelector('h1')?.textContent,feedback:document.querySelector('#feedback')?.textContent,statusRole:document.querySelector('#feedback')?.getAttribute('role'),live:document.querySelector('#feedback')?.getAttribute('aria-live')}));}
|
||||
async function tabTo(p,id){for(let i=0;i<12;i++){if((await inspect(p)).focus===id)return;await p.keyboard.press('Tab');}throw Error(`Tab failed to reach ${id}`);}
|
||||
async function selected(p,topic){await p.waitForFunction(t=>document.querySelector('h1')?.textContent===t,topic);assert.equal(new URL(p.url()).searchParams.get('topic'),topic);}
|
||||
async function settled(p){await p.waitForFunction(()=>window.probe.some(e=>e.name==='htmx:afterSettle'));}
|
||||
try{
|
||||
for(const [engine,launcher]of [['chromium',chromium],['firefox',firefox]]){
|
||||
const path=engine==='chromium'?process.env.FEP_CHROMIUM_PATH:undefined;
|
||||
const browser=await launcher.launch({headless:true,...(path?{executablePath:path}:{})});
|
||||
browsers[engine]={version:browser.version(),executable:path||launcher.executablePath(),sha256:createHash('sha256').update(await readFile(path||launcher.executablePath())).digest('hex')};
|
||||
try{
|
||||
async function scenario(id,mode,fn){
|
||||
requests=[];release=null;const context=await browser.newContext({javaScriptEnabled:mode!=='native'});const p=await context.newPage();p.setDefaultTimeout(4000);
|
||||
const r={id,engine,mode,status:'pass'};
|
||||
try{await p.goto(base+`/articles?topic=alpha&mode=${mode}`);if(mode!=='native')await p.waitForFunction(()=>window.htmx?.version==='2.0.11');r.observation=await fn(p);}
|
||||
catch(e){r.status='fail';r.error=e.message;}
|
||||
r.final=await inspect(p).catch(()=>null);r.events=await p.evaluate(()=>window.probe||[]).catch(()=>[]);r.requests=[...requests];results.push(r);
|
||||
if(release){release();release=null;}heldResolve=null;await context.close();await writeFile(output,JSON.stringify({status:'incomplete',results},null,2)+'\n');
|
||||
}
|
||||
await scenario('K01-native-keyboard','native',async p=>{await tabTo(p,'next');await p.keyboard.press('Enter');await selected(p,'beta');assert.equal(await p.title(),'beta — Catalog');return {keyboardLink:true};});
|
||||
for(const mode of ['raw','managed']){
|
||||
await scenario('K02-enhanced-keyboard',mode,async p=>{
|
||||
await tabTo(p,'next');await p.keyboard.press('Enter');await selected(p,'beta');await settled(p);const o=await inspect(p);
|
||||
o.headingContinuation=o.focus==='heading';o.updatedTitle=o.title==='beta — Catalog';o.completionFeedback=o.feedback==='Loaded beta';
|
||||
if(mode==='managed')assert(o.headingContinuation&&o.updatedTitle&&o.completionFeedback);return o;
|
||||
});
|
||||
await scenario('K03-error-feedback-recovery',mode,async p=>{
|
||||
await tabTo(p,'broken');await p.keyboard.press('Enter');await p.waitForFunction(()=>window.probe.some(e=>e.name==='htmx:responseError'));await selected(p,'alpha');const o=await inspect(p);assert.equal(o.focus,'broken');
|
||||
o.failureFeedback=o.feedback.includes('Navigation failed');if(mode==='managed'){assert(o.failureFeedback);assert.equal(o.statusRole,'status');assert.equal(o.live,'polite');}
|
||||
await tabTo(p,'recover');await p.keyboard.press('Enter');await selected(p,'beta');o.nativeRecovery=true;return o;
|
||||
});
|
||||
await scenario('K04-delayed-new-focus',mode,async p=>{
|
||||
const held=new Promise(resolve=>{heldResolve=resolve;});await tabTo(p,'slow');await p.keyboard.press('Enter');await held;await tabTo(p,'search');
|
||||
release();release=null;await selected(p,'beta');await settled(p);const o=await inspect(p);assert.equal(o.focus,'search');if(mode==='managed')assert.equal(o.feedback,'Loaded beta');return o;
|
||||
});
|
||||
await scenario('K05-history-continuation',mode,async p=>{
|
||||
await tabTo(p,'next');await p.keyboard.press('Enter');await selected(p,'beta');await settled(p);await p.goBack();await selected(p,'alpha');await p.waitForFunction(()=>window.probe.some(e=>e.name==='htmx:historyRestore'));
|
||||
const o=await inspect(p);if(mode==='managed'){assert.equal(o.focus,'heading');assert.equal(o.title,'alpha — Catalog');assert.equal(o.feedback,'Loaded alpha');}return o;
|
||||
});
|
||||
await scenario('K06-background-no-theft',mode,async p=>{
|
||||
await tabTo(p,'search');await p.evaluate(()=>htmx.ajax('GET','/news',{target:'#news',swap:'innerHTML'}));await p.locator('#news').filter({hasText:'Background news updated'}).waitFor();
|
||||
const o=await inspect(p);assert.equal(o.focus,'search');assert.equal(o.feedback,'');assert.equal(o.title,'alpha — Catalog');return o;
|
||||
});
|
||||
}
|
||||
}finally{await browser.close();}
|
||||
}
|
||||
}finally{if(release)release();await new Promise(resolve=>server.close(resolve));}
|
||||
const files={};for(const f of ['focus.mjs','focus-policy.js','package.json','package-lock.json','node_modules/htmx.org/dist/htmx.js'])files[f]=createHash('sha256').update(await readFile(new URL(f,dir))).digest('hex');
|
||||
const r={date:new Date().toISOString(),node:process.version,kind:'keyboard-focus-dom-observation-not-assistive-technology-validation',files,browsers,results,passed:results.filter(x=>x.status==='pass').length,total:results.length};await writeFile(output,JSON.stringify(r,null,2)+'\n');console.log(JSON.stringify({output,passed:r.passed,total:r.total,failures:results.filter(x=>x.status==='fail')},null,2));if(r.passed!==r.total)process.exitCode=1;
|
||||
Loading…
Add table
Add a link
Reference in a new issue