<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>gage</title>
<style>
:root{
--t1:11px; --t2:12.5px; --t3:14px; --t4:16px; --t5:20px;
--w1:400; --w2:550; --w3:680;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px;
--r1:6px; --r2:10px; --r3:999px;
--r:var(--r2);
--bg:#fbfaf8; --sheet:#ffffff; --sunk:#f3f1ec; --line:#e7e3dc; --line2:#d6d1c7;
--fg:#191815; --dim:#5b5751; --faint:#8b867d;
--accent:#2c5a4d; --accent-ink:#ffffff; --accent-soft:#e6eee9;
--ok:#2c6650; --warn:#8a5712; --bad:#9a2b2b;
--prompt:#8a5712; --memory:#9a2b2b; --decision:#45458a; --file:#2c5a4d;
--shadow:0 1px 2px rgba(25,24,21,.04), 0 10px 28px -20px rgba(25,24,21,.30);
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
--ease:140ms cubic-bezier(.2,.6,.3,1);
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--bg:#121316; --sheet:#1b1d21; --sunk:#232529; --line:#2b2e34; --line2:#3a3e45;
--fg:#edebe7; --dim:#a8a49d; --faint:#7a766f;
--accent:#86c7b1; --accent-ink:#0f1f1a; --accent-soft:#1f2f2a;
--ok:#86c7b1; --warn:#dcac60; --bad:#e8908a;
--prompt:#dcac60; --memory:#e8908a; --decision:#a8a8e4; --file:#86c7b1;
--shadow:0 1px 2px rgba(0,0,0,.45), 0 12px 32px -22px rgba(0,0,0,.9);
color-scheme:dark;
}}
:root[data-theme=dark]{
--bg:#121316; --sheet:#1b1d21; --sunk:#232529; --line:#2b2e34; --line2:#3a3e45;
--fg:#edebe7; --dim:#a8a49d; --faint:#7a766f;
--accent:#86c7b1; --accent-ink:#0f1f1a; --accent-soft:#1f2f2a;
--ok:#86c7b1; --warn:#dcac60; --bad:#e8908a;
--prompt:#dcac60; --memory:#e8908a; --decision:#a8a8e4; --file:#86c7b1;
--shadow:0 1px 2px rgba(0,0,0,.45), 0 12px 32px -22px rgba(0,0,0,.9);
color-scheme:dark;
}
*{box-sizing:border-box;margin:0;padding:0}
[hidden]{display:none!important}
html,body{height:100%}
body{background:var(--bg);color:var(--fg);font-family:var(--sans);
font-size:var(--t3);line-height:1.55;-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;font-variant-numeric:tabular-nums}
button,input,select,textarea{font:inherit;color:inherit}
button{background:none;border:0;cursor:pointer}
::selection{background:var(--accent-soft)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--r1)}
.state,.ty,.tag,.verdict,.bsec,.lanhead{font-size:var(--t1);letter-spacing:.07em;
text-transform:uppercase;font-weight:var(--w3)}
.app{display:grid;grid-template-columns:minmax(300px,348px) 1fr;height:100dvh}
@media (max-width:880px){
.app{grid-template-columns:1fr}
.right{display:none}
.app.detail .left{display:none}
.app.detail .right{display:flex}
}
.left{border-right:1px solid var(--line);background:var(--sheet);
display:flex;flex-direction:column;min-height:0}
.brand{display:flex;align-items:center;gap:var(--s3);padding:var(--s5) var(--s5) var(--s4)}
.logo{min-width:28px;height:26px;padding:0 var(--s2);border-radius:var(--r1);flex:none;
display:grid;place-items:center;background:var(--accent);color:var(--accent-ink);
font-weight:var(--w3);font-size:var(--t2);letter-spacing:.01em}
.wordmark{font-weight:var(--w3);font-size:var(--t4);letter-spacing:-.01em}
.whoami{margin-left:auto;text-align:right;min-width:0}
.whoami .h{font-size:var(--t2);font-weight:var(--w2);color:var(--dim)}
.whoami .k{font-family:var(--mono);font-size:var(--t1);color:var(--faint)}
.tabs{display:flex;gap:var(--s1);padding:0 var(--s5) var(--s3)}
.tab{font-size:var(--t2);padding:var(--s1) var(--s3);border-radius:var(--r3);cursor:pointer;
border:1px solid transparent;background:transparent;color:var(--dim);display:flex;
align-items:center;gap:var(--s2);transition:background var(--ease),color var(--ease)}
.tab:hover{background:var(--sunk);color:var(--fg)}
.tab.on{background:var(--accent-soft);border-color:transparent;color:var(--accent);
font-weight:var(--w2)}
.tab .bdg{font-size:var(--t1);min-width:17px;height:17px;border-radius:var(--r3);padding:0 5px;
display:grid;place-items:center;background:var(--bad);color:#fff;font-weight:var(--w3)}
.filters{display:flex;gap:var(--s1);padding:0 var(--s5) var(--s3)}
.chip{font-size:var(--t2);padding:var(--s1) var(--s3);border-radius:var(--r3);color:var(--dim);
border:1px solid transparent;transition:background var(--ease),color var(--ease)}
.chip:hover{background:var(--sunk);color:var(--fg)}
.chip.on{background:var(--accent-soft);color:var(--accent);font-weight:var(--w2)}
.chip .n{font-family:var(--mono);font-size:var(--t1);opacity:.7;margin-left:var(--s1)}
.list{overflow-y:auto;flex:1;min-height:0;padding:0 var(--s3) var(--s4);scrollbar-width:thin}
.t{width:100%;text-align:left;padding:var(--s3);border-radius:var(--r2);
display:grid;grid-template-columns:auto 1fr;gap:var(--s3);position:relative;
transition:background var(--ease)}
.t:hover{background:var(--sunk)}
.t.sel{background:var(--accent-soft)}
.t + .t{margin-top:2px}
.avatar{width:30px;height:30px;border-radius:var(--r1);flex:none;display:grid;
place-items:center;font-size:var(--t2);font-weight:var(--w3);margin-top:1px}
.tbody{min-width:0}
.trow{display:flex;gap:var(--s2);align-items:baseline}
.subj{font-weight:var(--w2);font-size:var(--t3);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;flex:1;letter-spacing:-.005em}
.when{font-family:var(--mono);font-size:var(--t1);color:var(--faint);flex:none}
.tmeta{display:flex;gap:var(--s2);align-items:center;margin-top:3px;font-size:var(--t2);
color:var(--dim);min-width:0}
.tmeta .peers{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pip{width:6px;height:6px;border-radius:50%;background:var(--accent);flex:none}
.state{padding:2px var(--s2);border-radius:var(--r1);flex:none;white-space:nowrap}
.s-you{color:var(--warn);background:color-mix(in srgb,var(--warn) 13%,transparent)}
.s-them{color:var(--dim);background:var(--sunk)}
.s-settled{color:var(--ok);background:color-mix(in srgb,var(--ok) 13%,transparent)}
.s-closed{color:var(--faint);background:var(--sunk)}
.empty{padding:var(--s7) var(--s5);color:var(--faint);text-align:center;
font-size:var(--t2);line-height:1.75}
.right{display:flex;flex-direction:column;min-height:0;background:var(--bg)}
.head{padding:var(--s4) var(--s5);border-bottom:1px solid var(--line);background:var(--sheet);
display:flex;gap:var(--s3);align-items:center;flex:none}
.back{display:none;font-size:var(--t4);color:var(--dim);padding:2px var(--s2)}
@media (max-width:880px){.back{display:block}}
.head h1{font-size:var(--t4);font-weight:var(--w3);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;letter-spacing:-.012em}
.head .sp{flex:1}
.headbtn{font-size:var(--t2);color:var(--dim);padding:var(--s1) var(--s3);border-radius:var(--r1);
border:1px solid var(--line2);white-space:nowrap;transition:border-color var(--ease),color var(--ease)}
.headbtn:hover{border-color:var(--accent);color:var(--accent)}
.msgs{overflow-y:auto;flex:1;min-height:0;padding:var(--s5) var(--s5) var(--s3);
display:flex;flex-direction:column;gap:var(--s5)}
.m{max-width:720px;min-width:0}
.m.mine{margin-left:auto}
.mhead{display:flex;gap:var(--s2);align-items:center;margin-bottom:var(--s2);
flex-wrap:wrap;padding-inline:2px}
.m.mine .mhead{justify-content:flex-end}
.who{font-weight:var(--w3);font-size:var(--t2)}
.ty{padding:2px var(--s2);border-radius:var(--r1);background:var(--sunk);color:var(--dim)}
.ty.question,.ty.review-request{color:var(--warn)}
.ty.answer{color:var(--ok)}
.at{font-family:var(--mono);font-size:var(--t1);color:var(--faint)}
.dot{width:6px;height:6px;border-radius:50%;background:var(--accent)}
.sheet{background:var(--sheet);border:1px solid var(--line);border-radius:var(--r2);
overflow:hidden;box-shadow:var(--shadow)}
.m.mine .sheet{background:var(--sunk);box-shadow:none}
.verdict{display:flex;gap:var(--s2);align-items:center;padding:var(--s3) var(--s4);
font-size:var(--t2);letter-spacing:.06em}
.verdict .vdot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none}
.v-ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 10%,transparent)}
.v-warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 10%,transparent)}
.v-bad{color:var(--bad);background:color-mix(in srgb,var(--bad) 12%,transparent)}
.stale{margin:0 var(--s3) var(--s2);padding:var(--s2) var(--s3);border-radius:var(--r1);
font-size:var(--t2);line-height:1.5;color:var(--bad);
border:1px solid color-mix(in srgb,var(--bad) 35%,transparent);
background:color-mix(in srgb,var(--bad) 10%,transparent)}
.alert{padding:var(--s3) var(--s4);font-size:var(--t2);line-height:1.55;color:var(--warn);
border-top:1px solid var(--line)}
.alert.bad{color:var(--bad)}
.alert b{font-weight:var(--w3)}
.alert code{font-family:var(--mono);font-size:var(--t1)}
.files{border-top:1px solid var(--line);padding:var(--s1) 0}
.f{display:flex;gap:var(--s3);align-items:center;padding:var(--s1) var(--s4);
font-family:var(--mono);font-size:var(--t1)}
.tag{padding:2px 0;border-radius:var(--r1);width:64px;text-align:center;flex:none;
font-family:var(--sans);background:var(--sunk);letter-spacing:.06em}
.m.mine .tag{background:var(--sheet)}
.t-file{color:var(--file)} .t-prompt{color:var(--prompt)}
.t-memory{color:var(--memory)} .t-decision{color:var(--decision)}
.fp{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fb{color:var(--faint);flex:none}
.acts{display:flex;gap:var(--s2);align-items:center;flex-wrap:wrap;
padding:var(--s3) var(--s4);border-top:1px solid var(--line)}
.btn{font-size:var(--t2);padding:var(--s1) var(--s3);border-radius:var(--r1);
border:1px solid var(--line2);white-space:nowrap;
transition:border-color var(--ease),color var(--ease),background var(--ease)}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn.go{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);
font-weight:var(--w2)}
.btn.go:hover{opacity:.88;color:var(--accent-ink)}
.btn.no{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,transparent)}
.btn.no:hover{border-color:var(--bad);color:var(--bad)}
.cmd{font-family:var(--mono);font-size:var(--t1);color:var(--faint);margin-left:auto;
user-select:all;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:55%}
.composer{flex:none;border-top:1px solid var(--line);background:var(--sheet);
padding:var(--s4) var(--s5)}
.cwrap{max-width:720px;margin-inline:auto;display:flex;flex-direction:column;gap:var(--s2)}
.cto{font-size:var(--t2);color:var(--dim);display:flex;gap:var(--s2);align-items:center;
flex-wrap:wrap}
.cto b{font-weight:var(--w3);color:var(--fg)}
.cto select{font-size:var(--t2);padding:2px var(--s2);border-radius:var(--r1);
border:1px solid var(--line2);background:var(--bg)}
textarea{width:100%;min-height:64px;max-height:40vh;resize:vertical;
background:var(--bg);border:1px solid var(--line2);border-radius:var(--r2);
padding:var(--s3);font-size:var(--t3);line-height:1.6;
transition:border-color var(--ease)}
textarea:focus{outline:none;border-color:var(--accent)}
.crow{display:flex;gap:var(--s3);align-items:center}
.chint{font-size:var(--t1);color:var(--faint)}
.cerr{font-size:var(--t2);color:var(--bad)}
.cblocked{font-size:var(--t2);color:var(--dim);line-height:1.55}
.lanbox{border-top:1px solid var(--line);padding:var(--s4) var(--s3) var(--s4);flex:none;
max-height:46dvh;overflow-y:auto}
.lanhead{display:flex;align-items:center;gap:var(--s2);color:var(--dim);
margin:0 0 var(--s3) var(--s2)}
.lanhead .dot{width:7px;height:7px;border-radius:50%;background:var(--faint);flex:none}
.lanhead .dot.on{background:var(--ok);box-shadow:0 0 0 3px color-mix(in srgb,var(--ok) 20%,transparent)}
.dev{border:1px solid var(--line);border-radius:var(--r2);padding:var(--s3);
margin-bottom:var(--s2);background:var(--bg);transition:border-color var(--ease)}
.dev.sel{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}
.dev.ask{border-color:color-mix(in srgb,var(--warn) 45%,var(--line));background:var(--sunk)}
.devtop{display:flex;align-items:baseline;gap:var(--s2);flex-wrap:wrap}
.devname{font-weight:var(--w3);font-size:var(--t3);letter-spacing:-.005em}
.devtag{font-size:var(--t1);padding:1px var(--s2);border-radius:var(--r3);
border:1px solid var(--line2);color:var(--dim);white-space:nowrap}
.devtag.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}
.devfp{font-size:var(--t2);color:var(--fg);margin-top:var(--s2);line-height:1.6;
font-family:var(--mono);word-break:break-word;letter-spacing:-.01em}
.devrow{display:flex;gap:var(--s2);margin-top:var(--s3);flex-wrap:wrap}
.devbtn{font-size:var(--t2);padding:var(--s1) var(--s3);border-radius:var(--r1);cursor:pointer;
border:1px solid var(--line2);background:transparent;color:var(--fg);
transition:border-color var(--ease),color var(--ease)}
.devbtn:hover{border-color:var(--accent);color:var(--accent)}
.lansub{font-size:var(--t2);color:var(--dim);margin-top:var(--s2);line-height:1.6}
.lanempty{font-size:var(--t2);color:var(--dim);line-height:1.6;margin-left:var(--s2)}
.lanin{font:inherit;font-size:var(--t2);padding:var(--s2);border-radius:var(--r1);
border:1px solid var(--line2);background:var(--bg);color:var(--fg);margin-top:var(--s2);
transition:border-color var(--ease)}
.lanin:focus{outline:none;border-color:var(--accent)}
.lanerr{font-size:var(--t2);color:var(--bad);margin-top:var(--s2);line-height:1.55}
.lanok{font-size:var(--t2);color:var(--ok);margin-top:var(--s2);line-height:1.55}
.drop{border:1.5px dashed var(--line2);border-radius:var(--r2);padding:var(--s4) var(--s3);
text-align:center;font-size:var(--t2);color:var(--dim);cursor:pointer;margin-top:var(--s2);
transition:border-color var(--ease),background var(--ease)}
.drop:hover,.drop.over{border-color:var(--accent);background:var(--accent-soft)}
.drop.has{border-style:solid;color:var(--fg)}
.buddies{padding:0 var(--s3) var(--s5);overflow-y:auto;flex:1}
.bsec{color:var(--dim);margin:var(--s5) 0 var(--s3) var(--s2);display:flex;
align-items:baseline;gap:var(--s2)}
.bsec:first-child{margin-top:var(--s3)}
.bgate{margin-top:var(--s3);padding-top:var(--s3);border-top:1px dashed var(--line2)}
.bwhy{font-size:var(--t2);color:var(--dim);line-height:1.65;margin-bottom:var(--s2)}
.bpair{font-family:var(--mono);font-size:var(--t2);color:var(--fg);background:var(--sunk);
border:1px solid var(--line);border-radius:var(--r1);padding:1px var(--s2)}
.bdots{display:flex;gap:var(--s3);margin-top:var(--s2);flex-wrap:wrap}
.breach{font-size:var(--t1);color:var(--faint);display:flex;align-items:center;gap:5px}
.breach.on{color:var(--ok)}
.breach i{width:6px;height:6px;border-radius:50%;background:currentColor;
display:inline-block;opacity:.5}
.breach.on i{opacity:1}
.bnum{font-size:var(--t1);padding:1px var(--s2);border-radius:var(--r3);
background:var(--accent);color:var(--accent-ink);font-weight:var(--w3)}
.berr{font-size:var(--t2);color:var(--bad);margin-top:var(--s3);line-height:1.6}
.bok{font-size:var(--t2);color:var(--ok);margin-top:var(--s3);line-height:1.6}
.hint{max-width:58ch;margin:var(--s6) 0 0;padding:0 var(--s5);color:var(--dim);
font-size:var(--t3);line-height:1.7}
.hint h2{font-size:var(--t5);font-weight:var(--w3);color:var(--fg);
letter-spacing:-.022em;margin-bottom:var(--s4)}
.hint p{margin-bottom:var(--s4)}
.hint code{font-family:var(--mono);font-size:var(--t2);background:var(--sunk);
border:1px solid var(--line);border-radius:var(--r1);padding:2px 6px;
-webkit-box-decoration-break:clone;box-decoration-break:clone}
</style>
<div class="app" id="app">
<div class="left">
<div class="brand">
<span class="logo">gage</span>
<span class="wordmark" id="wordmark">Conversations</span>
<span class="whoami" id="me"></span>
</div>
<div class="stale" id="stale" hidden></div>
<div class="tabs" id="tabs"></div>
<div class="filters" id="filters"></div>
<div class="list" id="list"></div>
<div class="lanbox" id="lanbox"></div>
<div class="buddies" id="buddies" hidden></div>
</div>
<div class="right">
<div class="head">
<button class="back" onclick="back()" title="Back">←</button>
<h1 id="title">gage</h1><span class="sp"></span>
<span id="hstate"></span>
<button class="headbtn" id="settle" hidden onclick="act('close',SEL)">Mark settled</button>
</div>
<div class="msgs" id="msgs"></div>
<div class="composer" id="composer" hidden></div>
<div class="msgs" id="budpane" hidden></div>
</div>
</div>
<script>
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const kb=n=>n<1024?n+' B':n<1048576?(n/1024).toFixed(1)+' KB':(n/1048576).toFixed(1)+' MB';
const ago=iso=>{const d=(Date.now()-Date.parse(iso))/1000;
if(!isFinite(d))return'';
if(d<60)return'now'; if(d<3600)return Math.floor(d/60)+'m';
if(d<86400)return Math.floor(d/3600)+'h'; return Math.floor(d/86400)+'d';};
const V=[['SENDER VERIFIED','Verified','v-ok'],
['SENDER NOT','Not on your list','v-warn'],
['SENDER REFUSED','Refused by you','v-warn'],
['SENDER KEY RETIRED','Retired key','v-warn'],
['SENDER KEY REJECTED','Key rejected','v-bad'],
['ARRIVAL UNRECORDED','Arrival unrecorded','v-bad'],
['SENDER UNVERIFIABLE','Unverifiable','v-bad'],
['VERDICT UNAVAILABLE','Verdict unavailable','v-bad']];
const vRow=t=>V.find(r=>(t||'').startsWith(r[0]));
const vClass=t=>{const r=vRow(t);return r?r[2]:'v-bad';};
const vShort=t=>{const r=vRow(t);return r?r[1]:(t||'No verdict');};
const sClass=s=>s.includes('YOU')?'s-you':s.includes('them')?'s-them':s==='closed'?'s-closed':'s-settled';
const sShort=s=>s.includes('YOU')?'needs you':s;
function keyHue(k){let h=0;for(const c of String(k||'')) h=(h*31+c.charCodeAt(0))%360;return h;}
function avatar(m){
const label=(m.known_as||m.claimed_name||'?').trim()[0]||'?';
if(m.mine) return `<span class="avatar" style="background:var(--accent);color:var(--accent-ink)">${esc(label.toUpperCase())}</span>`;
const h=keyHue(m.key||m.known_as||m.claimed_name);
return `<span class="avatar" style="background:hsl(${h} 42% 88%);color:hsl(${h} 55% 28%)">${esc(label.toUpperCase())}</span>`;
}
const nameOf=m=>m.mine?'you':(m.known_as?m.known_as:`"${m.claimed_name}"`);
let DATA={threads:[],contacts:[]}, LERR='', SEL=null, FILTER='all', DRAFT={}, CERR='', SENDING=false;
function alerts(m){
const out=[];
if(m.tampered&&m.tampered.length) out.push(['bad',
`Payload does not match the signed manifest (${esc(m.tampered.join(', '))}). Contents withheld.`]);
if(!m.mine && m.known_as && m.known_as!==m.claimed_name) out.push(['bad',
`Name mismatch. This key is <b>${esc(m.known_as)}</b> on your list, but the bundle claims "${esc(m.claimed_name)}". The claimed name is text the sender chose.`]);
if(!m.mine && !m.known_as) out.push(['',
`This key is not on your list. It claims the name "${esc(m.claimed_name)}", which is unverified.`]);
if(m.thread_ok===false) out.push(['bad',
`This message <b>claims</b> to belong to this conversation while answering something you do not hold. The thread field is written by the sender. Treat it as a separate message, and reply to the person you meant.`]);
if(m.forwarded) out.push(['',`Forwarded, ${m.hops} hop(s). The sender is not the author, and any hop could have altered it before signing.`]);
if(m.memories) out.push(['bad',
`${m.memories} memory file(s). Memory outlives the session that accepts it, so it is never merged automatically.`]);
if(m.prompts) out.push(['',
`${m.prompts} prompt(s): text written to steer a model. Read it with <code>gage open</code>, which frames it as data.`]);
return out.map(([c,h])=>`<div class="alert ${c}">${h}</div>`).join('');
}
function msgHtml(m){
const files=(m.items||[]).map(i=>`<div class="f">
<span class="tag t-${esc(i.kind)}">${esc(i.kind)}</span>
<span class="fp">${esc(i.path)}</span><span class="fb">${kb(i.bytes)}</span></div>`).join('');
const acts = m.stage==='pending'
? `<div class="acts">
<button class="btn go" onclick="act('accept','${esc(m.id)}')">Accept</button>
<button class="btn no" onclick="act('reject','${esc(m.id)}')">Reject unread</button>
<span class="cmd">arriving is not accepting</span></div>`
: m.stage==='accepted'
? `<div class="acts">
${m.read?'':`<button class="btn" onclick="act('read','${esc(m.id)}')">Mark read</button>`}
<span class="cmd">gage open ${esc(m.path)}</span></div>`
: `<div class="acts"><span class="cmd">sent · ${esc(m.id)}</span></div>`;
return `<div class="m ${m.mine?'mine':''}">
<div class="mhead">
${m.read?'':'<span class="dot"></span>'}
<span class="who">${esc(nameOf(m))}</span>
<span class="ty ${esc(m.type)}">${esc(m.type)}</span>
<span class="at">${esc(m.created)}</span>
</div>
<div class="sheet">
${m.mine?'':`<div class="verdict ${vClass(m.trust)}"><span class="vdot"></span>${vShort(m.trust)}</div>`}
${alerts(m)}
${files?`<div class="files">${files}</div>`:''}
${acts}
</div></div>`;
}
const TYPES=['answer','share','question','review-request','ack'];
function composerHtml(t){
const r=t.reply||{};
if(!r.can){
return `<div class="cwrap"><div class="cblocked">
Cannot reply from here: ${esc(r.why||'no recipient')}.
${r.to?`<br><code>gage reply ${esc(t.id)} --text "..."</code>`:''}
</div></div>`;
}
const d=DRAFT[t.id]||{text:'',type:r.type||'answer'};
return `<div class="cwrap">
${CERR?`<div class="cerr">${esc(CERR)}</div>`:''}
<div class="cto">Reply to <b>${esc(r.to)}</b> as
<select id="ctype" onchange="draft()">${TYPES.map(x=>
`<option value="${x}"${x===d.type?' selected':''}>${x}</option>`).join('')}</select>
</div>
<textarea id="ctext" placeholder="Write a reply. It is scanned for credentials on the way out, like any file."
oninput="draft()" onkeydown="ckey(event)">${esc(d.text)}</textarea>
<div class="crow">
<span class="chint">Delivered to ${esc(r.to)}'s inbox on this machine. ${SENDING?'Sending...':'⌘/Ctrl + Enter to send.'}</span>
<button class="btn go" onclick="send()" ${SENDING?'disabled':''}>Send reply</button>
</div></div>`;
}
function render(){
const me=DATA.me;
const st=document.getElementById('stale');
if(LERR){ st.hidden=false; st.textContent=LERR; }
else st.hidden=!DATA.verdicts_stale;
if(!LERR && DATA.verdicts_stale) st.textContent=
'Verdicts withheld: your inbox could not be recorded, so a verdict here could read '+
'milder than the truth. Usually another gage process is writing \u2014 it clears on '+
'the next poll. Until then, read bundles with gage open in a terminal.';
document.getElementById('me').innerHTML = me
? `<div class="h">${esc(me.handle)}</div><div class="k">${esc((me.key||'').slice(0,10))}…</div>`
: `<div class="h">no identity</div><div class="k">run gage init</div>`;
const th=DATA.threads||[];
const needYou=th.filter(t=>t.state.includes('YOU')).length;
const unread=th.reduce((a,t)=>a+t.unread,0);
document.getElementById('filters').innerHTML=
[['all','All',th.length],['you','Needs you',needYou],['unread','Unread',unread]]
.map(([k,l,n])=>`<button class="chip ${FILTER===k?'on':''}" onclick="setF('${k}')">${l}<span class="n">${n}</span></button>`).join('');
let shown=th;
if(FILTER==='you') shown=th.filter(t=>t.state.includes('YOU'));
if(FILTER==='unread') shown=th.filter(t=>t.unread>0);
document.getElementById('list').innerHTML = shown.length ? shown.map(t=>{
const last=t.messages[t.messages.length-1]||{};
const other=[...t.messages].reverse().find(m=>!m.mine)||last;
const who=[...new Set(t.messages.map(nameOf))].join(', ');
return `<button class="t ${SEL===t.id?'sel':''}" onclick="sel('${esc(t.id)}')">
${avatar(other)}
<span class="tbody">
<span class="trow"><span class="subj">${esc(t.subject)}</span>
<span class="when">${esc(ago(last.created))}</span></span>
<span class="tmeta">
${t.unread?'<span class="pip"></span>':''}
${t.correspondents>1?'<span class="state s-you">'+t.correspondents+' senders</span>':''}
<span class="state ${sClass(t.state)}">${esc(sShort(t.state))}</span>
<span class="peers">${esc(who)}</span></span>
</span>
</button>`;}).join('')
: `<div class="empty">Nothing here yet.<br>Bundles land in <b>pending</b> first.<br>Arriving is not accepting.</div>`;
if(TAB!=='chats') return;
const t=th.find(x=>x.id===SEL);
document.getElementById('title').textContent = t ? t.subject : 'gage';
document.getElementById('hstate').innerHTML = t
? `<span class="state ${sClass(t.state)}">${esc(sShort(t.state))}</span>` : '';
document.getElementById('settle').hidden = !t || t.state==='closed';
const comp=document.getElementById('composer');
comp.hidden = !t;
document.getElementById('msgs').innerHTML = t
? t.messages.map(msgHtml).join('')
: `<div class="hint">
<h2>Pick a conversation</h2>
<p>Threads group by the message that started them. What is read, and what
counts as settled, is yours: a sender cannot mark their own message
read in your inbox.</p>
<p><code>gage send --to codex --type question --decision q.md</code><br>
<code>gage reply <id> --text "..."</code></p>
<p>Replies written here are delivered to a local route. Files you drop
on a machine in <b>On this network</b> go straight to it, over the
wifi, and land unread in its inbox. Sending through a <i>relay</i> is
still <code>gage push</code> in a terminal, where you see the list
first.</p>
</div>`;
if(t){
const prev=document.activeElement&&document.activeElement.id;
const caret=prev==='ctext'?document.activeElement.selectionStart:null;
comp.innerHTML=composerHtml(t);
if(prev==='ctext'){
const el=document.getElementById('ctext');
if(el){el.focus();if(caret!=null)el.setSelectionRange(caret,caret);}
}
}
}
function setF(f){FILTER=f;render();}
function sel(id){SEL=id;CERR='';document.getElementById('app').classList.add('detail');render();
const m=document.getElementById('msgs');if(m)m.scrollTop=m.scrollHeight;}
function back(){document.getElementById('app').classList.remove('detail');SEL=null;render();}
function draft(){
const te=document.getElementById('ctext'), ty=document.getElementById('ctype');
if(SEL) DRAFT[SEL]={text:te?te.value:'',type:ty?ty.value:'answer'};
}
function ckey(e){ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();send();} }
const post=(path,body)=>fetch(path,{method:'POST',headers:
Object.assign({'X-Gage':'1'},body?{'Content-Type':'application/json'}:{}),
body:body?JSON.stringify(body):undefined});
async function act(what,id){
if(!id)return;
const r=await post(`/api/${what}/${id}`);
if(!r.ok){const j=await r.json().catch(()=>({}));CERR=j.error||'that did not work';}
await load();
}
async function send(){
draft();
const d=DRAFT[SEL]; if(!d||!d.text.trim()||SENDING) return;
SENDING=true;CERR='';render();
try{
const r=await post('/api/reply',{thread:SEL,text:d.text,type:d.type});
const j=await r.json().catch(()=>({}));
if(r.ok){ DRAFT[SEL]={text:'',type:d.type}; }
else { CERR=j.error||'the reply was not sent'; }
}catch(e){ CERR=String(e); }
SENDING=false;
await load();
}
async function load(){
try{
const r=await fetch('/api/threads');
if(!r.ok)throw new Error('HTTP '+r.status);
DATA=await r.json();
LERR='';
}catch(e){
LERR='Could not load your inbox from this server: '+(e&&e.message?e.message:e)
+'. The server is at '+location.origin+'; gage ui logs to the terminal it runs in.';
render();
return;
}
if(SEL&&!DATA.threads.some(t=>t.id===SEL))SEL=null;
render();
}
let LAN={lan_running:false,devices:[]}, LSEL=null, FILES=[], LERR2='', LOK='', LBUSY=false;
let LPAIR=null, LNAME='';
const PAIRHINT='Check all four pairs against what the other machine shows. They come '
+'from its key, so nobody picks their own. If one word differs, it is not that key.';
function lanRender(){
const box=document.getElementById('lanbox');
if(!box)return;
const on=LAN.lan_running;
let h=`<div class="lanhead"><span class="dot${on?' on':''}"></span>On this network</div>`;
if(!LAN.devices.length){
h+=`<div class="lanempty">${on
? 'Nobody else here yet.'
: 'Not on the network. Run <b>gage lan</b> in a terminal.'}</div>`
+`<div class="lansub">${on
? 'Run <b>gage lan</b> on the other machine too.'
: 'It announces while it runs and goes silent when you stop it.'}</div>`;
box.innerHTML=h; return;
}
for(const d of LAN.devices){
const sel=LSEL===d.label;
h+=`<div class="dev${sel?' sel':''}">
<div class="devtop">
<span class="devname">${esc(d.paired_as||d.label)}</span>
${d.paired_as
? `<span class="devtag ok">paired</span>`
: `<span class="devtag">not paired</span>`}
</div>
<div class="devfp">${esc(d.fingerprint)}</div>
<div class="devrow">
<button class="devbtn" onclick="lanPick('${esc(d.label)}')">${sel?'Chosen':'Send to this'}</button>
${d.paired_as?'':`<button class="devbtn" onclick="lanPair('${esc(d.label)}')">${
LPAIR===d.label?'Cancel':'Pair'}</button>`}
</div>
${LPAIR===d.label?`
<div class="lansub">${esc(PAIRHINT)}</div>
<input class="lanin" placeholder="Call it what on your list?" value="${esc(LNAME)}"
oninput="LNAME=this.value">
<div class="devrow">
<button class="devbtn" onclick="lanPairGo('${esc(d.label)}')" ${LBUSY?'disabled':''}>
${LBUSY?'Pairing...':'Those four pairs match'}</button>
</div>`:''}
</div>`;
}
const names=FILES.map(f=>f.name).join(', ');
h+=`<div class="drop${FILES.length?' has':''}" id="drop"
onclick="document.getElementById('fpick').click()">
${FILES.length?esc(names)+` · ${kb(FILES.reduce((a,f)=>a+f.size,0))}`
:'Drop files here, or click to choose'}
</div>
<input type="file" id="fpick" multiple hidden onchange="lanFiles(this.files)">`;
if(FILES.length&&LSEL){
h+=`<input class="lanin" id="lsubj" placeholder="Subject" value="${esc(LSUBJ)}"
oninput="LSUBJ=this.value">
<div class="devrow">
<button class="devbtn" onclick="lanSend()" ${LBUSY?'disabled':''}>
${LBUSY?'Sending...':'Send to '+esc((LAN.devices.find(x=>x.label===LSEL)||{}).paired_as||LSEL)}</button>
<button class="devbtn" onclick="FILES=[];lanRender()">Clear</button>
</div>`;
} else if(FILES.length){
h+=`<div class="lansub">Choose a machine above.</div>`;
}
if(LERR2)h+=`<div class="lanerr">${esc(LERR2)}</div>`;
if(LOK)h+=`<div class="lanok">${esc(LOK)}</div>`;
box.innerHTML=h;
const dz=document.getElementById('drop');
if(dz){
dz.ondragover=e=>{e.preventDefault();dz.classList.add('over')};
dz.ondragleave=()=>dz.classList.remove('over');
dz.ondrop=e=>{e.preventDefault();dz.classList.remove('over');lanFiles(e.dataTransfer.files)};
}
}
let LSUBJ='';
function lanPick(l){ LSEL=(LSEL===l?null:l); LERR2='';LOK=''; lanRender(); }
async function lanFiles(fl){
LERR2='';LOK='';
const out=[];
let total=0;
for(const f of fl){
total+=f.size;
if(total*1.37>11*1024*1024){
LERR2='That is too much to send through the page ('+kb(total)
+'). Use gage send --dir in the terminal for anything large.';
lanRender(); return;
}
out.push({name:f.name,size:f.size,data:await b64(f)});
}
FILES=out; lanRender();
}
const b64=f=>new Promise((res,rej)=>{
const r=new FileReader();
r.onerror=()=>rej(new Error('could not read '+f.name));
r.onload=()=>res(String(r.result).split(',',2)[1]||'');
r.readAsDataURL(f);
});
async function lanSend(){
if(!LSEL||!FILES.length||LBUSY)return;
LBUSY=true;LERR2='';LOK='';lanRender();
try{
const r=await post('/api/send',{to:LSEL,subject:LSUBJ,type:'share',
files:FILES.map(f=>({name:f.name,data:f.data}))});
const j=await r.json().catch(()=>({}));
if(r.ok){ LOK=(j.lines||[]).join('\n'); FILES=[]; LSUBJ=''; }
else { LERR2=j.error||'that did not send'; }
}catch(e){ LERR2=String(e); }
LBUSY=false; lanRender();
}
function lanPair(label){ LPAIR=(LPAIR===label?null:label); LNAME=''; LERR2='';LOK=''; lanRender(); }
async function lanPairGo(label){
const d=LAN.devices.find(x=>x.label===label);
if(!d||LBUSY)return;
if(!LNAME.trim()){ LERR2='Give it a name to file it under.'; lanRender(); return; }
LBUSY=true;LERR2='';LOK='';lanRender();
try{
const r=await post('/api/pair',{label:label,name:LNAME.trim(),fingerprint:d.fingerprint});
const j=await r.json().catch(()=>({}));
if(r.ok){ LOK=(j.lines||[]).join('\n'); LPAIR=null; LNAME=''; }
else { LERR2=j.error||'that did not pair'; }
}catch(e){ LERR2=String(e); }
LBUSY=false;
await lanLoad();
}
async function lanLoad(){
try{
const r=await fetch('/api/nearby');
if(!r.ok)throw new Error('HTTP '+r.status);
LAN=await r.json();
if(LSEL&&!LAN.devices.some(d=>d.label===LSEL))LSEL=null;
}catch(e){
LERR2='Could not read the network list: '+(e&&e.message?e.message:e);
}
lanRender();
}
lanLoad();setInterval(lanLoad,2000);
let BUD={buddies:[],requests:[],refused:[]}, TAB='chats';
let ASK=null, ANAME='', ACONF='', AERR='', AOK='', ABUSY=false, SHOWREF=false;
let REVEAL=null;
const ASKWHY='The word-pairs come from the key itself, so nobody picks their own. '
+'This screen does not show them yet, on purpose: read the pair below off the '
+'OTHER machine, where they must run gage fingerprint --signing-only. Get it '
+'right and the rest are shown, so you can check all four before adding.';
const ASKHALF='These words cover the SIGNING key only. A message does not carry its '
+'sender\u2019s encryption key, so adding them here lets you verify what they '
+'sign and not send them sealed mail. For both, get their `gage add` line and '
+'compare the normal four pairs.';
const ASKTRAP='If this pair came from the message itself, or from anything this '
+'key sent you, it proves nothing -- a key knows its own word-pairs and can '
+'quote them. It only means something if it came from the other machine.';
function tabsRender(){
const el=document.getElementById('tabs');
if(!el)return;
const n=BUD.requests.length;
el.innerHTML=
`<button class="tab${TAB==='chats'?' on':''}" onclick="setTab('chats')">Chats</button>`
+`<button class="tab${TAB==='buddies'?' on':''}" onclick="setTab('buddies')">Buddies`
+(n?`<span class="bdg">${n}</span>`:'')+`</button>`;
}
function setTab(t){
TAB=t;
hashSync();
for(const [id,show] of [['filters',t==='chats'],['list',t==='chats'],
['lanbox',t==='chats'],['buddies',t==='buddies'],
['msgs',t==='chats'],['budpane',t==='buddies']]){
const el=document.getElementById(id); if(el)el.hidden=!show;
}
const wm=document.getElementById('wordmark');
if(wm)wm.textContent = t==='buddies' ? 'Buddies' : 'Conversations';
if(t==='buddies'){
document.getElementById('title').textContent='Buddies';
const c=document.getElementById('composer'); if(c)c.hidden=true;
const se=document.getElementById('settle'); if(se)se.hidden=true;
document.getElementById('hstate').textContent='';
document.getElementById('budpane').innerHTML=`<div class="hint">
<h2>Who can reach you</h2>
<p>Your list is the trust boundary. A key on it is one you confirmed out of
band; a key not on it is a decision you have not taken yet. Nothing here is
automatic, and nothing a sender writes puts it on the list.</p>
<p>Approving one asks for a word-pair you have to read off the other machine.
That is not a formality: the pairs come from the key, so a key can quote its
own in a message to you, and only the other machine's screen is evidence.</p>
<p>Refusing records that you said no. It is not a block — nothing in gage
can stop a bundle being delivered, and mail from a refused key still arrives
and still waits for you to accept it.</p>
<p><code>gage decline <key></code> and <code>gage undecline <key></code>
do the same from a terminal.</p></div>`;
}else{
document.getElementById('title').textContent='gage';
}
tabsRender(); budRender();
}
function hashSync(){
let h='';
if(TAB==='buddies')h = ASK ? '#buddies='+encodeURIComponent(ASK) : '#buddies';
try{ history.replaceState(null,'',h||location.pathname); }catch(e){}
}
function hashRead(){
const h=decodeURIComponent(location.hash||'');
if(!h.startsWith('#buddies'))return;
TAB='buddies';
const i=h.indexOf('=');
if(i>0)ASK=h.slice(i+1);
}
function askOpen(k){
ASK=(ASK===k?null:k); ANAME='';ACONF='';AERR='';AOK='';
if(!ASK||(REVEAL&&REVEAL.key!==ASK))REVEAL=null;
hashSync(); budRender();
}
async function budReveal(k){
ABUSY=true;AERR='';AOK='';budRender();
try{
const r=await fetch('/api/connect',{method:'POST',
headers:{'X-Gage':'1','Content-Type':'application/json'},
body:JSON.stringify({key:k,name:'',confirm:ACONF})});
const j=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
REVEAL={key:k,fp:(j.lines||[])[1]||'',pair:ACONF};
ACONF='';
}catch(e){ AERR=(e&&e.message)?e.message:String(e); }
ABUSY=false; budRender();
}
function reachRow(r){
const bits=[
['routed under this name', r.routed_by_name,
'A local route is filed under this name, which is the name sending resolves too. '
+'It is your label, not their identity.'],
['on this network now', r.lan_now,
'This key is announcing itself on this network, which it chose to do by running '
+'gage lan. Nothing here polls anybody.'],
['mail can be left', r.can_leave_mail,
'You are joined to a relay and hold an encryption key for them, so mail CAN be '
+'left. This is about your setup, not about them -- whether they ever collect it '
+'is not something this machine can know.'],
];
return `<div class="bdots">`+bits.map(([t,on,title])=>
`<span class="breach${on?' on':''}" title="${esc(title)}"><i></i>${t}</span>`).join('')+`</div>`;
}
function budRender(){
const box=document.getElementById('buddies');
if(!box||TAB!=='buddies')return;
let h='';
if(BUD.requests.length){
h+=`<div class="bsec">Not on your list<span class="bnum">${BUD.requests.length}</span></div>`
+`<div class="lansub" style="margin-bottom:8px">Keys that have sent you something and
are not on your list. A message is not a request for anything — deciding is
yours, and doing nothing is a decision the list will keep offering.</div>`;
for(const q of BUD.requests){
const open=ASK===q.key;
const shown=(REVEAL&&REVEAL.key===q.key)?REVEAL.fp:null;
h+=`<div class="dev ask">
<div class="devtop"><span class="devname">${esc(q.label)}</span>
<span class="devtag">${q.bundles} message${q.bundles===1?'':'s'}</span>
</div>
<div class="bwhy" style="margin-top:5px">${esc(vShort(q.verdict))}
${q.first_observed?'· first seen here '+esc(ago(q.first_observed)):''}
· pair 1 of its fingerprint is <b>${esc(q.label)}</b></div>
${shown?`<div class="devfp">${esc(shown)}</div>`:''}
<div class="devrow">
<button class="devbtn" onclick="askOpen('${esc(q.key)}')">${open?'Cancel':'Add to my list'}</button>
<button class="devbtn" onclick="budDecline('${esc(q.key)}',false)" ${ABUSY?'disabled':''}
title="Records that you said no. It does not block anything: mail from this key still arrives and still waits for you to accept it.">Refuse</button>
</div>
<div class="bwhy">Refusing records the decision so you are not asked again. It does
not stop anything arriving.</div>`;
if(open&&!shown){
h+=`<div class="bgate">
<div class="bwhy">${esc(ASKWHY)}</div>
<div class="bwhy">${esc(ASKHALF)}</div>
<div class="bwhy" style="color:var(--warn)">${esc(ASKTRAP)}</div>
<div class="bwhy">Type pair <b>${q.ask_pair}</b> of four, as the other machine shows it.</div>
<input class="lanin" placeholder="pair ${q.ask_pair}, e.g. two words" value="${esc(ACONF)}"
oninput="ACONF=this.value" onkeydown="if(event.key==='Enter')budReveal('${esc(q.key)}')"
style="width:100%">
<div class="devrow">
<button class="devbtn" onclick="budReveal('${esc(q.key)}')" ${ABUSY?'disabled':''}>
${ABUSY?'Checking...':'Check this pair'}</button>
</div></div>`;
}else if(open&&shown){
h+=`<div class="bgate">
<div class="bwhy">Pair ${q.ask_pair} matched. Now check <b>all four</b> above against
the other machine before you add anything — one matching pair is 16 bits, and
a key can be ground out to produce a chosen one.</div>
<input class="lanin" placeholder="Call them what on your list?" value="${esc(ANAME)}"
oninput="ANAME=this.value" style="width:100%">
<div class="devrow">
<button class="devbtn" onclick="budConnect('${esc(q.key)}')" ${ABUSY?'disabled':''}>
${ABUSY?'Adding...':'All four match -- add them'}</button>
</div></div>`;
}
h+=`</div>`;
}
}
h+=`<div class="bsec">Your list<span class="bnum">${BUD.buddies.length}</span></div>`;
if(!BUD.buddies.length){
h+=`<div class="lansub">Nobody yet. A key that sends you something shows up above,
and pairing on this network adds one with its encryption key as well.</div>`;
}
for(const b of BUD.buddies){
h+=`<div class="dev">
<div class="devtop"><span class="devname">${esc(b.name)}</span>
${b.unread?`<span class="bnum">${b.unread} unread</span>`:''}
${b.sealable?'<span class="devtag ok">sealable</span>'
:'<span class="devtag" title="A bundle does not carry its sender\'s encryption key, so a contact added from a message is verify-only.">verify only</span>'}</div>
<div class="devfp">${esc(b.fingerprint)}</div>
${reachRow(b.reach)}
</div>`;
}
if(BUD.refused.length){
h+=`<div class="bsec"><a href="#" onclick="SHOWREF=!SHOWREF;budRender();return false"
style="color:inherit">Refused (${BUD.refused.length}) ${SHOWREF?'▾':'▸'}</a></div>`;
if(SHOWREF){
h+=`<div class="lansub" style="margin-bottom:8px">Refusing records a decision. It does
not block anything — nothing in gage can stop a bundle being delivered, and
mail from these keys still arrives and still waits for you to accept it. What it
buys is that you are not asked about these again. Their word-pairs are not shown
here, so that un-refusing one puts you back in front of the same check.</div>`;
for(const r of BUD.refused){
h+=`<div class="dev">
<div class="devtop"><span class="devname">${esc(r.label)}</span>
<span class="devtag">refused ${esc(r.at)}</span>
${r.holding?`<span class="devtag">${r.holding} here now</span>`:''}</div>
<div class="devrow">
<button class="devbtn" onclick="budDecline('${esc(r.key)}',true)" ${ABUSY?'disabled':''}>
Un-refuse</button>
</div></div>`;
}
}
}
if(AERR)h+=`<div class="berr">${esc(AERR)}</div>`;
if(AOK)h+=`<div class="bok">${esc(AOK)}</div>`;
box.innerHTML=h;
}
async function budPost(path,body){
ABUSY=true;AERR='';AOK='';budRender();
try{
const r=await fetch(path,{method:'POST',headers:{'X-Gage':'1','Content-Type':'application/json'},
body:JSON.stringify(body)});
const j=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
AOK=(j.lines||[]).filter(x=>x&&x.trim()).join(' ');
ASK=null;ANAME='';ACONF='';REVEAL=null;
}catch(e){ AERR=(e&&e.message)?e.message:String(e); }
ABUSY=false; await budLoad(); load();
}
function budConnect(k){
const p = (REVEAL && REVEAL.key===k) ? REVEAL.pair : ACONF;
budPost('/api/connect',{key:k,name:ANAME,confirm:p});
}
function budDecline(k,undo){ budPost('/api/decline',{key:k,undo:!!undo}); }
async function budLoad(){
try{
const r=await fetch('/api/buddies');
if(!r.ok)throw new Error('HTTP '+r.status);
BUD=await r.json();
if(ASK&&!BUD.requests.some(q=>q.key===ASK))ASK=null;
}catch(e){
AERR='Could not read your list: '+(e&&e.message?e.message:e);
}
tabsRender(); budRender();
}
budLoad();setInterval(budLoad,2500);
hashRead();
setTab(TAB);
load();setInterval(load,2500);
</script>