allwright 0.1.32

allwright CLI that bundles the lightweight engine core and plugin management.
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Allwright Inspector</title>
  <style>
    :root { color-scheme: dark; --bg:#0a0d12; --panel:#111620; --line:#252c38; --muted:#8d98aa; --text:#eef2f8; --accent:#7ce2bd; --select:#67a9ff; }
    * { box-sizing:border-box } body { margin:0; height:100vh; overflow:hidden; background:var(--bg); color:var(--text); font:13px/1.45 ui-sans-serif,system-ui,sans-serif }
    header { height:54px; display:flex; align-items:center; gap:12px; padding:0 18px; border-bottom:1px solid var(--line); background:#0d1118 }
    .mark { width:28px; height:28px; display:grid; place-items:center; border-radius:8px; color:#07110e; background:var(--accent); font-weight:900 }
    header strong { font-size:15px } header span { color:var(--muted) } button,input { font:inherit } button { border:1px solid var(--line); border-radius:7px; padding:7px 11px; color:var(--text); background:#171e29; cursor:pointer } button:hover { border-color:#4d5a6d } button.primary { color:#06120d; border-color:var(--accent); background:var(--accent); font-weight:700 } .spacer { flex:1 }
    .modeSwitch { display:flex; padding:3px; border:1px solid var(--line); border-radius:9px; background:#090d13 } .modeSwitch button { padding:5px 10px; border:0; background:transparent } .modeSwitch button.active { color:#07110e; background:var(--accent); font-weight:750 }
    main { height:calc(100vh - 54px); min-height:0; display:grid; grid-template-columns:minmax(460px,1fr) 360px 330px }
    section { min-width:0; min-height:0; height:100%; overflow:hidden; border-right:1px solid var(--line); background:var(--panel) } .title { height:42px; display:flex; align-items:center; padding:0 14px; border-bottom:1px solid var(--line); color:#cbd3df; font-weight:650 }
    #stageWrap { height:calc(100% - 42px); display:flex; align-items:center; justify-content:center; overflow:hidden; padding:24px; background-color:#090c11; background-image:radial-gradient(#26303d 1px,transparent 1px); background-size:18px 18px }
    #stage { position:relative; flex:0 0 auto; box-shadow:0 18px 60px #000a } #shot { display:block; width:100%; height:100%; object-fit:contain; cursor:crosshair; user-select:none } body.interactMode #shot { cursor:pointer }
    #highlight { position:absolute; pointer-events:none; border:2px solid var(--select); background:#67a9ff25; box-shadow:0 0 0 1px #07111dcc; display:none }
    #tree { height:calc(100% - 42px); min-height:0; overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable; padding:8px } .node { width:max-content; min-width:100%; white-space:nowrap } .nodeRow { display:flex; align-items:center; gap:5px; min-height:25px; padding:2px 6px; border-radius:5px; cursor:pointer } .nodeRow:hover { background:#1a2230 } .nodeRow.active { background:#19304d; color:#fff } .twisty { width:12px; color:#647186 } .role { color:var(--accent) } .name { color:#d8dee8 } .children { margin-left:14px }
    #details { height:calc(100% - 42px); min-height:0; overflow:auto; overscroll-behavior:contain; scrollbar-gutter:stable; padding:14px } .empty { color:var(--muted); padding:16px 2px } .property { display:grid; grid-template-columns:100px 1fr; gap:8px; padding:7px 0; border-bottom:1px solid #202733 } .property label { color:var(--muted) } .property div { min-width:0; overflow-wrap:anywhere } .locator { margin:15px 0 8px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.08em } .locatorList { display:grid; gap:8px } .locatorItem { border:1px solid var(--line); border-radius:7px; overflow:hidden; background:#090d13 } .locatorHead { display:flex; align-items:center; justify-content:space-between; min-height:30px; padding:3px 5px 3px 10px; border-bottom:1px solid var(--line); color:var(--muted); font-size:11px; font-weight:700 } .locatorHead button { padding:3px 7px; border:0; background:transparent; font-size:11px } code { display:block; padding:10px; color:#b7ddff; overflow-wrap:anywhere; white-space:pre-wrap } .actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px } .interactionFields { display:grid; gap:8px; margin-top:10px } .actionField { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:7px } .actionField input { min-width:0; padding:8px 9px; border:1px solid var(--line); border-radius:7px; outline:none; color:var(--text); background:#090d13 } .actionField input:focus { border-color:var(--select) } #status.error { color:#ff8c8c }
    @media (max-width:1000px) { main { grid-template-columns:1fr 330px } section.treePane { display:none } }
  </style>
</head>
<body>
  <header><div class="mark">A</div><strong>Allwright Inspector</strong><span id="surface">desktop · native</span><div class="spacer"></div><div class="modeSwitch" aria-label="Inspector mode"><button id="inspectMode" class="active">Inspect</button><button id="interactMode">Interact</button></div><span id="status">Connecting…</span><button id="refresh">Refresh</button></header>
  <main>
    <section><div class="title">Application window</div><div id="stageWrap"><div id="stage"><img id="shot" draggable="false"><div id="highlight"></div></div></div></section>
    <section class="treePane"><div class="title">Element hierarchy</div><div id="tree"></div></section>
    <section><div class="title">Element details</div><div id="details"><div class="empty">Choose an element in the screenshot or hierarchy.</div></div></section>
  </main>
  <script>
    const $ = id => document.getElementById(id); let root, nodes=[], selected, mode='inspect', clickTimer, scrollTimer, touchStart, suppressClickUntil=0;
    function flatten(node, depth=0) { if (!node) return; node._depth=depth; nodes.push(node); (node.children||[]).forEach(n=>flatten(n,depth+1)); }
    function locator(n) { if (n.automationId) return `id=${n.automationId}`; const spec={kind:'role',role:n.role||'custom'}; if(n.name){spec.name=n.name;spec.exact=true} return `aw=${JSON.stringify(spec)}`; }
    function quoted(value) { return JSON.stringify(String(value)); }
    function rustQuoted(value) { value=String(value); let hashes=''; while(value.includes(`"${hashes}`)) hashes+='#'; return `r${hashes}"${value}"${hashes}`; }
    function clientLocators(n) {
      const id=n.automationId&&String(n.automationId), role=String(n.role||'custom'), name=n.name&&String(n.name);
      if(id) return [
        ['TypeScript',`app.getByTestId(${quoted(id)})`],
        ['Python',`app.get_by_test_id(${quoted(id)})`],
        ['Java',`app.getByTestId(${quoted(id)})`],
        ['Go',`app.GetByTestId(${quoted(id)})`],
        ['Rust',`app.get_by_test_id(${rustQuoted(id)})`],
      ];
      return [
        ['TypeScript',name?`app.getByRole(${quoted(role)}, { name: ${quoted(name)}, exact: true })`:`app.getByRole(${quoted(role)})`],
        ['Python',name?`app.get_by_role(${quoted(role)}, name=${quoted(name)}, exact=True)`:`app.get_by_role(${quoted(role)})`],
        ['Java',name?`app.getByRole(${quoted(role)}, new RoleOptions().setName(${quoted(name)}).setExact(true))`:`app.getByRole(${quoted(role)})`],
        ['Go',name?`app.GetByRole(${quoted(role)}, allwright.RoleOptions{Name: ${quoted(name)}, Exact: true})`:`app.GetByRole(${quoted(role)})`],
        ['Rust',name?`app.get_by_role_with_options(${rustQuoted(role)}, RoleOptions { name: Some(TextMatcher::from(${rustQuoted(name)})), exact: true, ..Default::default() })`:`app.get_by_role(${rustQuoted(role)})`],
      ];
    }
    function renderTree() { $('tree').replaceChildren(treeNode(root)); }
    function treeNode(n) { const wrap=document.createElement('div'); wrap.className='node'; const row=document.createElement('div'); row.className='nodeRow'+(n===selected?' active':''); const kids=n.children||[]; row.innerHTML=`<span class="twisty">${kids.length?'▾':''}</span><span class="role">${escapeHtml(n.role||'element')}</span><span class="name">${escapeHtml(n.name||n.automationId||'')}</span>`; row.onclick=e=>{e.stopPropagation();choose(n)}; wrap.append(row); if(kids.length){const children=document.createElement('div');children.className='children';kids.forEach(k=>children.append(treeNode(k)));wrap.append(children)} return wrap; }
    function escapeHtml(v){const d=document.createElement('div');d.textContent=v;return d.innerHTML}
    function positionHighlight(n){ const b=n&&n.bounds||{}, rb=root&&root.bounds||{}, image=$('shot'); if(!b.width||!b.height){$('highlight').style.display='none';return} const sx=image.clientWidth/Math.max(1,rb.width||image.naturalWidth), sy=image.clientHeight/Math.max(1,rb.height||image.naturalHeight); Object.assign($('highlight').style,{display:'block',left:`${(b.x-(rb.x||0))*sx}px`,top:`${(b.y-(rb.y||0))*sy}px`,width:`${b.width*sx}px`,height:`${b.height*sy}px`}); }
    function choose(n){ selected=n; renderTree(); positionHighlight(n); const values={role:n.role,name:n.name,automationId:n.automationId,className:n.className,enabled:n.enabled,offscreen:n.offscreen,bounds:n.bounds}; const snippets=clientLocators(n); const locatorList=snippets.map(([client,code],index)=>`<div class="locatorItem"><div class="locatorHead"><span>${client}</span><button data-copy-locator="${index}">Copy</button></div><code>${escapeHtml(code)}</code></div>`).join(''); $('details').innerHTML=Object.entries(values).map(([k,v])=>`<div class="property"><label>${k}</label><div>${escapeHtml(typeof v==='object'?JSON.stringify(v):String(v??''))}</div></div>`).join('')+`<div class="locator">Interact with element</div><div class="actions"><button class="primary" id="clickElement">Click / tap</button><button id="doubleClickElement">Double-click / tap</button><button id="scrollUp">Scroll up</button><button id="scrollDown">Scroll down</button></div><div class="interactionFields"><div class="actionField"><input id="fillValue" aria-label="Fill value" placeholder="Text to fill"><button id="fillElement">Fill</button></div><div class="actionField"><input id="keyValue" aria-label="Key to press" placeholder="Enter, Tab, Escape…"><button id="pressElement">Press key</button></div></div><div class="locator">Locator code</div><div class="locatorList">${locatorList}</div>`; document.querySelectorAll('[data-copy-locator]').forEach(button=>button.onclick=()=>navigator.clipboard.writeText(snippets[Number(button.dataset.copyLocator)][1])); $('clickElement').onclick=()=>perform('click',n); $('doubleClickElement').onclick=()=>perform('double_click',n); $('scrollUp').onclick=()=>perform('scroll',n,{direction:'up'}); $('scrollDown').onclick=()=>perform('scroll',n,{direction:'down'}); $('fillElement').onclick=()=>perform('fill',n,{value:$('fillValue').value}); $('pressElement').onclick=()=>perform('press',n,{key:$('keyValue').value}); $('fillValue').onkeydown=event=>{if(event.key==='Enter')$('fillElement').click()}; $('keyValue').onkeydown=event=>{if(event.key==='Enter')$('pressElement').click()}; }
    function hitAt(event){ if(!root)return; const rect=$('shot').getBoundingClientRect(), rb=root.bounds||{}; const x=(rb.x||0)+(event.clientX-rect.left)*(rb.width/rect.width), y=(rb.y||0)+(event.clientY-rect.top)*(rb.height/rect.height); return nodes.filter(n=>{const b=n.bounds;return b&&b.width>0&&b.height>0&&x>=b.x&&x<=b.x+b.width&&y>=b.y&&y<=b.y+b.height}).sort((a,b)=>(a.bounds.width*a.bounds.height)-(b.bounds.width*b.bounds.height))[0]; }
    function pick(event){ if(Date.now()<suppressClickUntil)return;const hit=hitAt(event); if(!hit)return; if(mode==='inspect'){choose(hit);return} clearTimeout(clickTimer);clickTimer=setTimeout(()=>perform('click',hit),220); }
    function doublePick(event){ if(mode!=='interact')return; const hit=hitAt(event);if(!hit)return;clearTimeout(clickTimer);perform('double_click',hit); }
    function wheelInteract(event){if(mode!=='interact')return;event.preventDefault();const hit=hitAt(event);if(!hit)return;const direction=Math.abs(event.deltaY)>=Math.abs(event.deltaX)?(event.deltaY>0?'down':'up'):(event.deltaX>0?'right':'left');clearTimeout(scrollTimer);scrollTimer=setTimeout(()=>perform('scroll',hit,{direction}),80);}
    function touchBegin(event){if(mode!=='interact'||event.touches.length!==1)return;const touch=event.touches[0];touchStart={x:touch.clientX,y:touch.clientY,hit:hitAt(touch)};}
    function touchEnd(event){if(mode!=='interact'||!touchStart||!event.changedTouches.length)return;const touch=event.changedTouches[0],dx=touch.clientX-touchStart.x,dy=touch.clientY-touchStart.y,hit=touchStart.hit;touchStart=undefined;if(!hit||Math.max(Math.abs(dx),Math.abs(dy))<30)return;event.preventDefault();suppressClickUntil=Date.now()+400;const direction=Math.abs(dy)>=Math.abs(dx)?(dy<0?'down':'up'):(dx<0?'right':'left');perform('scroll',hit,{direction});}
    function setMode(next){ mode=next;document.body.classList.toggle('interactMode',mode==='interact');$('inspectMode').classList.toggle('active',mode==='inspect');$('interactMode').classList.toggle('active',mode==='interact');$('status').textContent=mode==='inspect'?'Select an element':'Live interaction enabled'; }
    function fitStage(){ const wrap=$('stageWrap'), stage=$('stage'), image=$('shot'); if(!image.naturalWidth||!image.naturalHeight)return; const style=getComputedStyle(wrap), availableWidth=Math.max(1,wrap.clientWidth-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight)), availableHeight=Math.max(1,wrap.clientHeight-parseFloat(style.paddingTop)-parseFloat(style.paddingBottom)), scale=Math.min(1,availableWidth/image.naturalWidth,availableHeight/image.naturalHeight); stage.style.width=`${image.naturalWidth*scale}px`;stage.style.height=`${image.naturalHeight*scale}px`;if(selected)positionHighlight(selected); }
    async function load(payload){ root=payload.tree.root; $('surface').textContent=`${payload.surface} · ${payload.backend}`; nodes=[];flatten(root); const image=$('shot'); await new Promise((ok,no)=>{image.onload=ok;image.onerror=no;image.src=payload.image}); fitStage(); renderTree(); selected=undefined;$('highlight').style.display='none';$('details').innerHTML='<div class="empty">Choose an element in the screenshot or hierarchy.</div>'; }
    async function request(path,options){ $('status').className='';$('status').textContent='Loading…';try{const response=await fetch(path,options);const data=await response.json();if(!response.ok)throw new Error(data.error||response.statusText);await load(data);$('status').textContent=`${nodes.length} elements`;return true}catch(error){$('status').className='error';$('status').textContent=error.message;return false}}
    async function perform(action,n=selected,extra={}){if(!n)return;const selector=locator(n),ok=await request('/api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,selector,...extra})});if(ok){const current=nodes.find(node=>locator(node)===selector);if(current)choose(current)}}
    $('shot').addEventListener('click',pick); $('shot').addEventListener('dblclick',doublePick); $('shot').addEventListener('wheel',wheelInteract,{passive:false}); $('shot').addEventListener('touchstart',touchBegin,{passive:true}); $('shot').addEventListener('touchend',touchEnd,{passive:false}); $('inspectMode').onclick=()=>setMode('inspect'); $('interactMode').onclick=()=>setMode('interact'); $('refresh').onclick=()=>request('/api/snapshot'); window.addEventListener('resize',fitStage); request('/api/snapshot');
  </script>
</body>
</html>