app
Google Antigravity
Public Made by Adomby adom
Google Antigravity in Adom Hydrogen: the editor extension, the agy CLI and a dock dashboard that installs both into your container.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
#!/usr/bin/env python3
"""Shared shape for the sample Hydrogen widgets: a tiny HTTP server that serves one page for
the title-bar slot and a JSON feed the page polls. A widget binary only needs: serve on
$HYDROGEN_WIDGET_PORT, answer GET / with HTML sized for a ~30 px strip, and keep the JSON fresh.
Hydrogen hands the slot over as soon as GET / answers (or the binary can POST
$ADOM_HD_CONTROL_URL/widgets/navigate {id, url} itself).
Views (John 2026-09-15, "the font size is too small"): a widget that returns `items`
(label, pct, severity) gets a set of layouts for free and the user clicks the slot to cycle
them: every meter as rows (the original), one item alone as a big number, one item as a ring
gauge, any pair side by side, all items as numbers, all items as rings. Each view asks the
Title Bar for exactly the width it needs, so a single number takes a third of the space the
rows did and its digits fill the slot's height. The chosen view is remembered on disk.
Robustness (John 2026-09-15, "HTTP Error 404" after a reboot): the last good numbers are
saved to disk and shown dimmed from the first second after a restart; a provider error is said
in plain words ("no answer from OpenAI (HTTP 404), retrying") and retried at 5, 15, 45 s
before backing off; the page polls again at the server's own retry time, never three minutes
later."""
import json, os, sys, time, threading, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PAGE = """<!doctype html><meta charset="utf-8"><title>%(title)s</title>
<style>
html,body{margin:0;height:100%%;background:transparent;color:#e6ebef;font-family:"Familjen Grotesk","Satoshi",system-ui,sans-serif;overflow:hidden}
/* Two layouts, chosen by the slot's height (the Title Bar, full or compact), like the meter:
tall = rows stacked to use the height; short = one horizontal row. */
.w{display:flex;align-items:center;height:100vh;padding:0 5px;gap:5px;white-space:nowrap;cursor:pointer;user-select:none}
.mark{display:inline-flex;flex:none;width:15px;height:15px}.mark svg{width:100%%;height:100%%}
.rows{display:flex;align-items:center;gap:8px;min-width:0;flex:1 1 auto}
.row{display:flex;align-items:center;gap:4px}
.lbl{opacity:.75;letter-spacing:.04em;text-transform:uppercase}
.bar{position:relative;border-radius:3px;background:rgba(255,255,255,.14);overflow:hidden}
.bar i{position:absolute;left:0;top:0;bottom:0;background:#19b3a6;border-radius:3px}
.bar.warn i{background:#f0a060}.bar.hot i{background:#e5484d}
.v{font-variant-numeric:tabular-nums;text-align:right}
.big{font-weight:600;font-variant-numeric:tabular-nums}
.err{color:#f0a060}
/* An error is a button: a click checks again now (John 2026-09-17: "make this clickable so i can
manually refresh it after i login", and "i can't read this text its so small"). */
#wrap.act{cursor:pointer}#wrap.act:hover .err{text-decoration:underline;text-underline-offset:2px}
.checking{opacity:.7}
/* Stale (last numbers while the provider is between answers): a light fade only, so the colours
read the same as a fresh neighbour (John 2026-09-15: "the claude one has washed out colors"). */
#w.stale{opacity:.78}
/* Views: a big number per item (cell), pairs with a hairline between (sep), ring gauges. */
.cell{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:1px;min-width:0}
.pct{font-weight:700;font-variant-numeric:tabular-nums;line-height:1;color:#19b3a6;letter-spacing:-.01em}
.pct.warn{color:#f0a060}.pct.hot{color:#e5484d}
.sep{flex:none;width:1px;align-self:stretch;background:rgba(255,255,255,.18);margin:0 2px}
.rw{display:flex;align-items:center;gap:4px}
.ring{position:relative;flex:none}.ring svg{width:100%%;height:100%%;transform:rotate(-90deg);display:block}
.ring .in{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-weight:700;font-variant-numeric:tabular-nums;line-height:1}
@media (max-height:33px){
.w{font-size:clamp(7px,37vh,12px);line-height:1.2}
.lbl{font-size:clamp(6px,30vh,9px)}.bar{width:clamp(24px,130vh,40px);height:clamp(4px,20vh,6px)}.v{min-width:2.4em}
.big{font-size:clamp(9px,47vh,14px)}.err{font-size:clamp(9px,52vh,12px)}
.mark{width:clamp(10px,55vh,17px);height:clamp(10px,55vh,17px)}
.cell{flex-direction:row;align-items:baseline;gap:3px}
.pct{font-size:clamp(9px,66vh,14px)}.cell .lbl{font-size:clamp(6px,32vh,8px)}
.ring{width:clamp(10px,80vh,17px);height:clamp(10px,80vh,17px)}.ring .in{display:none}
}
@media (min-height:34px){
/* Tall: rows stacked. Columns take their natural width (label, value) and the bar takes the
rest, so nothing is ever clipped whatever the slot width; a long label is trimmed with an
ellipsis instead of pushing the value out of view. */
/* Narrow on purpose (John 2026-09-14: "make this 1/3rd the width"): the bar is a short gauge,
not a ruler; the label and value carry the number. About 90 px for three rows with a mark. */
.w{font-size:8px;line-height:1;padding:1px 4px;gap:4px}
.mark{width:13px;height:13px}
.rows{flex-direction:column;align-items:stretch;justify-content:center;gap:2px;min-width:0}
.rows.hz{flex-direction:row;align-items:center;gap:6px}
.row{display:grid;grid-template-columns:auto minmax(18px,1fr) auto;align-items:center;gap:3px;min-width:0}
.lbl{font-size:6.5px}.row .lbl{max-width:4.2em;overflow:hidden;text-overflow:ellipsis}.bar{width:auto;height:4px}.v{font-size:8px;min-width:0}
.big{font-size:14px}.err{font-size:11.5px;white-space:normal;line-height:1.15;max-width:150px}
/* Big numbers fill the 36 px the slot gives: 22 px digits over a 7 px label. */
.cell .lbl{font-size:7px}.pct{font-size:22px}
/* One percentage size in every view (John 2026-09-15: Claude's all-numbers view sat at 11.6 px
beside Codex's single number at 13.2 px, and the two widgets are always side by side). */
.nums .cell{gap:2px}
.ring{width:30px;height:30px}.ring .in{font-size:9.5px}
.rw .cell .lbl{font-size:7px}
}
@media (max-height:33px){ .err{overflow:hidden;text-overflow:ellipsis;max-width:100%%} }
%(x_css)s
</style><body><div class="w" id="wrap">%(mark_html)s<div class="rows" id="w">%(title)s…</div></div>
<div class="x" id="x"></div>
<script>
const R=%(refresh_ms)d;
function sev(p,s){return (s==='critical'||p>=90)?'hot':(s==='warning'||p>=70)?'warn':'';}
function bar(l,p,s){const c=sev(p,s);return `<span class="row"><span class="lbl">${l}</span><span class="bar ${c}"><i style="width:${Math.max(0,Math.min(100,p))}%%"></i></span><span class="v">${Math.round(p)}%%</span></span>`;}
function cell(it){return `<span class="cell"><span class="lbl">${it.label}</span><span class="pct ${sev(it.pct,it.severity)}">${Math.round(it.pct)}%%</span></span>`;}
function ring(it,withLabel){const p=Math.max(0,Math.min(100,it.pct));const r=15.5,c=2*Math.PI*r;const col={hot:'#e5484d',warn:'#f0a060'}[sev(it.pct,it.severity)]||'#19b3a6';
const g=`<span class="ring"><svg viewBox="0 0 36 36"><circle cx="18" cy="18" r="${r}" fill="none" stroke="rgba(255,255,255,.14)" stroke-width="4.5"/><circle cx="18" cy="18" r="${r}" fill="none" stroke="${col}" stroke-width="4.5" stroke-dasharray="${(p/100*c).toFixed(1)} ${c.toFixed(1)}" stroke-linecap="round"/></svg><span class="in">${Math.round(p)}</span></span>`;
const compact=window.innerHeight<=33;
const lab=compact?`<span class="cell"><span class="lbl">${it.label}</span><span class="pct ${sev(it.pct,it.severity)}">${Math.round(p)}%%</span></span>`:`<span class="lbl">${it.label}</span>`;
return `<span class="rw">${g}${withLabel?lab:''}</span>`;}
/* The views a set of items offers, in click order. */
function viewsFor(items){const n=items.length;const v=[{id:'rows',name:'all meters'}];
items.forEach((it,i)=>v.push({id:'big:'+i,name:it.label+' only'}));
items.forEach((it,i)=>v.push({id:'ring:'+i,name:it.label+' as a ring'}));
for(let i=0;i<n;i++)for(let j=i+1;j<n;j++)v.push({id:'duo:'+i+','+j,name:items[i].label+' + '+items[j].label});
if(n>1){v.push({id:'nums',name:'all as numbers'});v.push({id:'rings',name:'all as rings'});}
return v;}
/* A widget whose default is 'nums' (all as numbers) and that has a single limit gets that limit as
one big number, the same look: 'nums' needs two items, and falling to the first view (rows) showed
Codex's lone week as a thin meter (John 2026-10-01). */
const DEF_VIEW='%(default_view)s';
let viewName='';
let curView=(function(){try{return localStorage.getItem('hw.view')||DEF_VIEW;}catch(e){return DEF_VIEW;}})();
function renderItems(items,id){const [kind,arg]=String(id).split(':');const at=(k)=>items[Math.max(0,Math.min(items.length-1,+k||0))];
switch(kind){
case 'big':return cell(at(arg));
case 'ring':return ring(at(arg),true);
case 'duo':{const [a,b]=String(arg||'0,1').split(',');return cell(at(a))+'<span class="sep"></span>'+cell(at(b));}
case 'nums':return '<span class="nums rw">'+items.map(cell).join('<span class="sep"></span>')+'</span>';
case 'rings':return '<span class="nums rw">'+items.map(it=>ring(it,true)).join('')+'</span>';
default:return items.map(b=>bar(b.label,b.pct,b.severity)).join('');}}
/* d.stale = the last good data while the provider is unreachable or the token is between refreshes:
drawn dimmed, the reason in the tooltip, never an error where numbers were. */
function render(d0){const d=d0&&d0.stale?d0.stale:d0;const el=document.getElementById('w');const wrap=document.getElementById('wrap');
el.classList.toggle('stale',!!(d0&&d0.stale));let tip=(d0&&d0.stale)?String(d0.error||''):'';
if(d0&&d0.view&&d0.view!==curView){curView=d0.view;try{localStorage.setItem('hw.view',curView);}catch(e){}}
if(d&&Array.isArray(d.items)&&d.items.length){const vs=viewsFor(d.items);
/* A default may name a limit by its label ('big:week'): the week sits at a different place in
each widget (Claude 5h first, Codex and Antigravity week first). John 2026-10-18: every usage
widget opens on its week as one big number. */
const byLabel=(id)=>{const m=/^(big|ring):([^0-9].*)$/.exec(String(id));if(!m)return id;const i=d.items.findIndex(it=>it.label===m[2]);return m[1]+':'+(i<0?0:i);};
curView=byLabel(curView);let k=vs.findIndex(v=>v.id===curView);if(k<0){k=vs.findIndex(v=>v.id===byLabel(DEF_VIEW));if(k<0&&DEF_VIEW==='nums')k=vs.findIndex(v=>v.id==='big:0');if(k<0)k=0;curView=vs[k].id;}
el.classList.toggle('hz',curView!=='rows');el.innerHTML=renderItems(d.items,curView);
tip=(tip?tip+'. ':'')+'%(title)s: '+vs[k].name+'. Click for the next view ('+(k+1)+' of '+vs.length+').';}
else{el.classList.remove('hz');el.innerHTML=%(render_js)s;tip=(d0&&d0.error?String(d0.error)+'. ':'')+'Click to check again now.';}
wrap.classList.toggle('act',!!(d0&&d0.error));
/* Inside Hydrogen the hover card shows the expanded view below, which says all of this in full
sentences; the browser tooltip only sat on top of that card (Drew 2026-10-12). Standalone (a
browser tab, /preview) keeps it. */
wrap.title=(window.parent===window)?tip:'';viewName=(d&&Array.isArray(d.items)&&d.items.length)?(viewsFor(d.items).find(v=>v.id===curView)||{}).name||'':'';
postWidth();renderX(d0);}
/* The click's own re-render must carry the NEW view: window.__last is the last poll's copy, whose
view field still names the old one, and render() adopts the server's view over the local one.
Without this line a click changed the server and nothing on screen until the next poll, three
minutes later (Ray, adom/hydrogen#81, 2026-09-15). */
function setView(id){curView=id;try{localStorage.setItem('hw.view',id);}catch(e){}
if(window.__last)window.__last.view=id;
fetch('/view',{method:'POST',body:id}).then(r=>{if(!r.ok)throw new Error('http '+r.status);}).catch(()=>{setTimeout(()=>fetch('/view',{method:'POST',body:id}).catch(()=>{}),3000);});
if(window.__last)render(window.__last);}
/* CHECK NOW: POST /refresh makes the server ask the provider (or re-read the sign-in file) right away
instead of at its next scheduled check, and answers with the fresh data. One call per click. */
let refreshing=false;
async function refreshNow(showChecking){if(refreshing)return;refreshing=true;
if(showChecking){const el=document.getElementById('w');el.classList.remove('hz');el.innerHTML='<span class="err checking">checking…</span>';postWidth();}
try{const r=await fetch('/refresh',{method:'POST',cache:'no-store'});if(!r.ok)throw new Error('http '+r.status);const d=await r.json();window.__last=d;render(d);}
catch(e){tick();}finally{refreshing=false;}}
document.getElementById('wrap').addEventListener('click',()=>{const d0=window.__last;const d=d0&&d0.stale?d0.stale:d0;
if(d0&&d0.error){if(!d||!Array.isArray(d.items)||!d.items.length){refreshNow(true);return;}refreshNow(false);}
if(!d||!Array.isArray(d.items)||!d.items.length)return;
const vs=viewsFor(d.items);const k=vs.findIndex(v=>v.id===curView);setView(vs[(k+1)%%vs.length].id);});
/* THE WIDGET DECIDES ITS WIDTH: ask the host for the intrinsic width of what is shown in the
current layout (tall rows are narrow, the compact row is wide). Measured on a hidden clone laid
out at max-content, so the slot's current width never feeds back into the answer (rows stretch to
the slot, so measuring the live boxes would only ever grow). Works for any markup, not just rows. */
function postWidth(){try{const w=document.querySelector('.w');if(!w)return;
/* Moved into the hover card, the slot layout is hidden and measures 0: keep the slot's width. */
if(window.innerHeight>=100)return;
const c=w.cloneNode(true);c.querySelectorAll('[id]').forEach(e=>e.removeAttribute('id'));
c.style.cssText='position:absolute;left:-10000px;top:0;width:max-content;height:100vh;visibility:hidden;pointer-events:none';
document.body.appendChild(c);const total=Math.ceil(c.getBoundingClientRect().width)+2;c.remove();
if(total>0&&window.parent!==window)window.parent.postMessage({hdWidget:{width:total}},'*');}catch(e){}}
/* A failed poll (the widget process restarting, the proxy between the page and it) shows the last
data dimmed and tries again in 5 s, then 15, then 45, then the normal interval: never "no data"
for three minutes over a blip (AdomLapper 2026-09-14). When the server itself is waiting on the
provider it says when it will try again (retry_in) and the page asks again right after. */
let fails=0,qt=null;function quick(ms){clearTimeout(qt);qt=setTimeout(tick,ms);}
async function tick(){try{const r=await fetch('/data.json',{cache:'no-store'});if(!r.ok)throw new Error('http '+r.status);const d=await r.json();fails=0;window.__last=d;render(d);
if(d.error&&typeof d.retry_in==='number'&&d.retry_in*1000<R)quick(Math.max(2000,d.retry_in*1000+500));}
catch(e){fails++;if(window.__last){render({error:'no answer from the widget, retrying',stale:window.__last.stale||window.__last});}else{render({error:'no answer from the widget, retrying'});}
quick([5000,15000,45000][Math.min(fails-1,2)]);}}
/* align_ms: fire on the boundary (a clock ticks once per minute, on the minute) instead of every R ms. */
const A=%(align_ms)d;let tm=null;function arm(){if(tm)return;if(A){const ms=A-(Date.now()%%A)+50;tm=setTimeout(()=>{tm=null;tick();arm();},ms);}else tm=setInterval(tick,R);}function disarm(){if(tm){if(A)clearTimeout(tm);else clearInterval(tm);tm=null;}}
document.addEventListener('visibilitychange',()=>{if(document.hidden)disarm();else{tick();arm();}});
tick();if(!document.hidden)arm();
/* The slot resizes when the Title Bar goes compact or tall; the iframe's own window gets the resize event and the
height media queries above re-evaluate. Re-render too, for widgets whose render depends on the size. */
let rz=null;window.addEventListener('resize',()=>{clearTimeout(rz);rz=setTimeout(()=>{if(window.__last)render(window.__last);},80);});
%(x_js)s
</script></body>"""
# ── The hover view (Drew 2026-10-12: "show beautiful, human-facing details on hover ... a meter to
# show time remaining until my usage limits reset"). Hydrogen 1.0.429+ moves this same frame into
# the widget's hover card when the page asks for it ({hdWidget:{expand:{width,height}}}), so the
# page swaps layouts on HEIGHT: under 100 px it is the title-bar slot above, at 100 px and up it is
# this view. Per limit: a ring that drains as the window runs out (the time left until it resets),
# the percent used with a bar, a tick on the bar where an even pace through the window would be, the
# reset time in the viewer's own clock, and one sentence on whether the current pace runs out first.
# Widgets whose items carry no resets_at/window_s still get the bars and names, just no ring.
# These two are plain strings (no %-formatting), filled into PAGE as values.
X_CSS = r"""
.x{display:none;box-sizing:border-box;padding:10px 14px 10px;color:#e6ebef;font-size:12px;line-height:1.35;max-height:100vh;overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.18) transparent;cursor:default;user-select:none}
@media (min-height:100px){.w{display:none!important}.x{display:block}}
.xhd{display:flex;align-items:center;justify-content:space-between;gap:9px;font-size:11px;color:rgba(230,235,239,.6)}
.xhd .pl{padding:1px 7px;border-radius:9px;border:1px solid rgba(255,255,255,.14);color:rgba(230,235,239,.8)}
.xac{padding-top:3px;font-size:11px;color:rgba(230,235,239,.6);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.xac b{font-weight:500;color:rgba(230,235,239,.85)}
.xl{display:grid;grid-template-columns:54px minmax(0,1fr);gap:12px;align-items:center;padding:10px 0;border-top:1px solid rgba(255,255,255,.07)}
.xl.nr{grid-template-columns:minmax(0,1fr)}
.xhd+.xl,.xac+.xl,.xe+.xl{border-top:0}
.cd{position:relative;width:54px;height:54px}
.cd svg{width:100%;height:100%;transform:rotate(-90deg);display:block}
.cd circle{fill:none;stroke-width:4.5}
.cd .trk{stroke:rgba(255,255,255,.08)}.cd .arc{stroke:#8c9eff;stroke-linecap:round;transition:stroke-dasharray .8s ease}
.cd.soon .arc{stroke:#19b3a6}
.cd .ct{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1}
.cd .ct b{font-size:14px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
.cd .ct small{font-size:9px;opacity:.6;margin-top:2px;font-variant-numeric:tabular-nums}
.xh{display:flex;align-items:baseline;justify-content:space-between;gap:8px}
.xn{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.xp{flex:none;font-size:19px;font-weight:700;font-variant-numeric:tabular-nums;color:#19b3a6;line-height:1}
.xp small{font-size:10px;font-weight:500;opacity:.6;margin-left:3px;color:#e6ebef}
.xp.warn{color:#f0a060}.xp.hot{color:#e5484d}
.ub{position:relative;height:7px;margin:7px 0 6px;border-radius:4px;background:rgba(255,255,255,.09)}
.ub i{position:absolute;left:0;top:0;bottom:0;border-radius:4px;background:linear-gradient(90deg,#0f8f86,#19b3a6);transition:width .6s ease}
.ub.warn i{background:linear-gradient(90deg,#c9783f,#f0a060)}.ub.hot i{background:linear-gradient(90deg,#b8323a,#e5484d)}
.ub em{position:absolute;top:-3px;bottom:-3px;width:2px;margin-left:-1px;border-radius:1px;background:#e6ebef;opacity:.7}
.xs{font-size:11px;color:rgba(230,235,239,.72)}
.xs .pc{display:block;margin-top:2px}.xs .pc.warn{color:#f0a060}.xs .pc.hot{color:#e5484d}
.xe{margin:8px 0 2px;padding:7px 9px;border-radius:6px;background:rgba(240,160,96,.1);color:#f3c9a2;font-size:11px;display:flex;align-items:center;gap:8px}
.xe button{font:inherit;font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06);color:#e6ebef;cursor:pointer;flex:none}
.xe button{margin-left:auto}.xe button:hover{border-color:#19b3a6;color:#19b3a6}
.x.stale .xl{opacity:.62}
.xf{margin-top:2px;padding-top:8px;border-top:1px solid rgba(255,255,255,.07);font-size:10.5px;color:rgba(230,235,239,.55);display:flex;flex-direction:column;gap:3px}
.xf .cr{color:rgba(230,235,239,.8)}
"""
X_JS = r"""
/* ── The hover view. Built from the same data the slot draws; the ring and the sentences tick on
the page's own clock (once a second, only while the view is showing), never by asking again. */
const XW=380;
function esc(v){return String(v==null?'':v).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
function sevOf(it){return sev(it.pct,it.severity);}
function clock(t){const d=new Date(t*1000),n=new Date();const hm=d.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});
const day=x=>new Date(x.getFullYear(),x.getMonth(),x.getDate()).getTime();const dd=Math.round((day(d)-day(n))/86400000);
if(dd===0)return hm;if(dd===1)return 'tomorrow '+hm;if(dd>1&&dd<7)return d.toLocaleDateString([],{weekday:'short'})+' '+hm;
return d.toLocaleDateString([],{month:'short',day:'numeric'})+' '+hm;}
function span(s){s=Math.max(0,Math.round(s));const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);
if(d)return d+' day'+(d>1?'s':'')+(h?' '+h+' h':'');if(h)return h+' h'+(m?' '+m+' min':'');if(m)return m+' min';return s+' s';}
/* The ring's centre: the biggest unit, and the next one under it. */
function ringText(s){s=Math.max(0,Math.round(s));const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);
if(s<=0)return ['now',''];if(d)return [d+'d',h+'h'];if(h)return [h+'h',m+'m'];if(m)return [m+'m',(s%60)+'s'];return [s+'s',''];}
/* One sentence per limit: has it run out, will the current pace run out before the reset, or how
much is likely to be used by then. Pace = percent used over the share of the window gone by. */
function paceText(it,now){const at=it.resets_at,W=it.window_s;
if(it.pct>=100)return {c:'hot',t:'Limit reached. You are back '+(at?(new Date(at*1000).toDateString()===new Date().toDateString()?'at ':'')+clock(at):'when it resets')+'.'};
if(!at||!W)return null;const left=at-now;if(left<=0)return {c:'',t:'Resetting now.'};
const gone=Math.max(0,Math.min(1,1-left/W));
if(it.pct<=0)return {c:'',t:'Nothing used yet in this window.'};
/* Under half an hour in, a projection is noise. */
if(gone*W<1800)return {c:'',t:'This window just started.'};
const proj=it.pct/gone;
if(proj>=100){const rate=it.pct/(gone*W),t100=now+(100-it.pct)/rate,early=at-t100;
return {c:early>W*0.02?'warn':'',t:'At this pace you hit the limit around '+clock(t100)+(early>=60?', '+span(early)+' before it resets.':', right as it resets.')};}
if(proj>=80)return {c:'',t:'On pace for about '+Math.round(proj)+'% by the reset.'};
return {c:'',t:'Plenty of room: on pace for about '+Math.round(proj)+'%.'};}
function ago(t){if(!t)return '';const s=Date.now()/1000-t;if(s<60)return 'Updated just now';if(s<3600)return 'Updated '+Math.round(s/60)+' min ago';return 'Updated at '+new Date(t*1000).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});}
function money(v,cur){try{return new Intl.NumberFormat([],{style:'currency',currency:cur||'USD'}).format(v);}catch(e){return '$'+Number(v).toFixed(2);}}
const R_C=2*Math.PI*23;
function limRow(it,i){const c=sevOf(it);const ring=!!(it.resets_at&&it.window_s);const p=Math.max(0,Math.min(100,it.pct));
return `<div class="xl${ring?'':' nr'}" data-i="${i}">`+(ring?`<div class="cd" title="Time left until this limit resets"><svg viewBox="0 0 54 54"><circle class="trk" cx="27" cy="27" r="23"/><circle class="arc" cx="27" cy="27" r="23" stroke-dasharray="0 ${R_C.toFixed(1)}"/></svg><div class="ct"><b></b><small></small></div></div>`:'')+
`<div><div class="xh"><span class="xn">${esc(it.name||it.label)}</span><span class="xp ${c}">${Math.round(it.pct)}%<small>used</small></span></div>`+
`<div class="ub ${c}"><i style="width:${p}%"></i>${ring?'<em title="Even pace: where usage would be if spread evenly over the window"></em>':''}</div>`+
`<div class="xs"><span class="rs"></span><span class="pc"></span></div></div></div>`;}
let xKey='',xData=null,xTimer=null,xKick=0,lastExpand='';
function xShowing(){return window.innerHeight>=100;}
function renderX(d0){const x=document.getElementById('x');if(!x)return;
const d=d0&&d0.stale?d0.stale:d0;xData=d0;const items=(d&&Array.isArray(d.items))?d.items:[];
/* Nothing to say (a widget on this helper that draws no items, and no error): no bigger view,
the card keeps its plain brief. */
if(!items.length&&!(d0&&d0.error)){x.innerHTML='';withdrawExpand();return;}
/* No title here: Hydrogen's card already shows the widget's name and mark right above this view.
One quiet line instead: how fresh the numbers are, and the plan when the provider says. Under
it, which account the numbers belong to (d.account), so a second sign-in is never a mystery
(John 2026-10-18: Antigravity showed 0% for one account while the team account was at 14%). */
const plan=d&&d.plan?esc(String(d.plan).charAt(0).toUpperCase()+String(d.plan).slice(1))+' plan':'';
let h=`<div class="xhd"><span class="ag"></span>${plan?`<span class="pl">${plan}</span>`:''}</div>`;
if(d&&d.account)h+=`<div class="xac" title="${esc(d.account)}">Signed in as <b>${esc(d.account)}</b></div>`;
if(d0&&d0.error){const msg=d0.error==='starting'?'Checking your limits…':d0.error;
h+=`<div class="xe"><span>${esc(msg.charAt(0).toUpperCase()+msg.slice(1))}${items.length&&d0.at?'. Showing the numbers from '+new Date(d0.at*1000).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'})+'.':''}</span>${d0.error==='starting'?'':'<button class="xr">Check now</button>'}</div>`;}
h+=items.map(limRow).join('');
const foot=[];
if(d&&d.credits)foot.push(`<span class="cr">Usage credits: ${money(d.credits.used,d.credits.currency)} of ${money(d.credits.limit,d.credits.currency)} used</span>`);
if(items.some(it=>it.resets_at&&it.window_s))foot.push('<span>Ring: time left until the limit resets. Tick: an even pace through the window.</span>');
if(items.length)foot.push(`<span>Click the widget to change its look${viewName?' (now: '+esc(viewName)+')':''}.</span>`);
if(foot.length)h+=`<div class="xf">${foot.join('')}</div>`;
x.classList.toggle('stale',!!(d0&&d0.stale));x.innerHTML=h;
const b=x.querySelector('.xr');if(b)b.addEventListener('click',e=>{e.stopPropagation();b.textContent='Checking…';refreshNow(false);});
tickX();postExpand();armX();}
/* The live parts: ring arcs, countdowns, reset times, pace sentences, "updated n min ago". */
function tickX(){const x=document.getElementById('x');if(!x||!xData)return;const d=xData.stale||xData;const items=(d&&d.items)||[];const now=Date.now()/1000;
const ag=x.querySelector('.ag');if(ag)ag.textContent=ago(xData.at);
let due=false;
x.querySelectorAll('.xl').forEach(row=>{const it=items[+row.dataset.i];if(!it)return;const at=it.resets_at,W=it.window_s;
const rs=row.querySelector('.rs'),pc=row.querySelector('.pc');
if(at&&W){const left=at-now;if(left<=0)due=true;const frac=Math.max(0,Math.min(1,left/W));
const arc=row.querySelector('.arc');if(arc)arc.setAttribute('stroke-dasharray',(frac*R_C).toFixed(2)+' '+R_C.toFixed(1));
const cd=row.querySelector('.cd');if(cd)cd.classList.toggle('soon',frac<0.1);
const [big,small]=ringText(left);const ct=row.querySelector('.ct');if(ct){ct.querySelector('b').textContent=big;ct.querySelector('small').textContent=small;}
const em=row.querySelector('.ub em');if(em)em.style.left=((1-frac)*100).toFixed(2)+'%';
if(rs)rs.textContent=left>0?'Resets in '+span(left)+' · '+clock(at):'Resetting now';}
else if(rs)rs.textContent=at?'Resets '+clock(at):'';
const pt=paceText(it,now);if(pc){pc.textContent=pt?pt.t:'';pc.className='pc'+(pt&&pt.c?' '+pt.c:'');}});
/* A window just rolled over: ask once for the fresh numbers instead of showing 80% on a new window. */
if(due&&Date.now()-xKick>60000){xKick=Date.now();setTimeout(()=>refreshNow(false),4000);}}
function armX(){if(xShowing()&&!document.hidden){if(!xTimer)xTimer=setInterval(tickX,1000);}else if(xTimer){clearInterval(xTimer);xTimer=null;}}
window.addEventListener('resize',armX);document.addEventListener('visibilitychange',armX);
/* Ask Hydrogen for the bigger surface, sized to this view's content (measured on a hidden copy at
the expanded width, like postWidth). Sent only when it changes; Hydrogen drops the request on
every frame load, and every load renders, so it is re-sent then. */
function postExpand(){try{const x=document.getElementById('x');if(!x||window.parent===window)return;
const c=x.cloneNode(true);c.removeAttribute('id');c.style.cssText='display:block;position:absolute;left:-10000px;top:0;width:'+XW+'px;max-height:none;height:auto;visibility:hidden;pointer-events:none';
document.body.appendChild(c);const h=Math.ceil(c.getBoundingClientRect().height)+2;c.remove();
const k=XW+'x'+h;if(k===lastExpand)return;lastExpand=k;window.parent.postMessage({hdWidget:{expand:{width:XW,height:h}}},'*');}catch(e){}}
function withdrawExpand(){if(lastExpand==='none'||window.parent===window)return;lastExpand='none';try{window.parent.postMessage({hdWidget:{expand:null}},'*');}catch(e){}}
window.addEventListener('load',()=>{if(lastExpand!=='none'){lastExpand='';postExpand();}});
/* Keys stay inside an iframe: hand Escape to Hydrogen so it closes the card like everywhere else. */
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&window.parent!==window)window.parent.postMessage({hdWidget:{collapse:true}},'*');});
"""
def _state_path(title):
d = os.path.expanduser('~/.adom/widgets'); os.makedirs(d, exist_ok=True)
return os.path.join(d, ''.join(ch if ch.isalnum() else '-' for ch in title.lower()) + '.state.json')
def _load_state(title):
try: return json.load(open(_state_path(title)))
except Exception: return {}
def _save_state(title, st):
try:
p = _state_path(title); tmp = p + '.tmp'
json.dump(st, open(tmp, 'w')); os.replace(tmp, p)
except Exception: pass
def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0, default_view='rows'):
# mark = {'svg': '<path .../>', 'color': '#hex', 'viewBox': '0 0 W H'}: viewBox is the grid the
# path is drawn on (default 0 0 16 16). A 24-unit path in the 16-unit box is cut off at the right
# and bottom (the Antigravity arch, John 2026-10-17 and again 2026-10-18).
mark = mark or {'svg': '', 'color': 'currentColor'}
port = int(os.environ.get('HYDROGEN_WIDGET_PORT') or (sys.argv[sys.argv.index('--port')+1] if '--port' in sys.argv else 8930))
provider = 'Anthropic' if 'Claude' in title else ('OpenAI' if 'Codex' in title else ('Antigravity' if 'Antigravity' in title else 'the provider'))
# One upstream call per interval at most, however often the page asks. On an upstream error
# the next tries come at 5, 15, 45, 90 s, then minutes, capped at 15; back to normal on the
# next success. The last good numbers live on disk so a restart shows them at once, dimmed.
st = _load_state(title)
# The view a fresh install opens on: the widget's choice (every usage widget: the week as one big number, John 2026-10-18; was every limit as a
# big number in a row, John 2026-10-01; Codex was "the week only" from 2026-09-19), until the
# user clicks to another one, which is remembered on disk and wins from then on.
cache = {'t': 0, 'v': {'error': 'starting'}, 'wait': refresh_ms/1000, 'good': None, 'fails': 0, 'view': str(st.get('view') or default_view)}
if isinstance(st.get('good'), dict) and st['good'].get('items') is not None:
cache['good'] = st['good']; cache['good_at'] = st.get('at')
when = time.strftime('%H:%M', time.localtime(st.get('at') or 0)) if st.get('at') else 'earlier'
cache['v'] = {'error': 'waiting for %s, last numbers from %s' % (provider, when), 'stale': cache['good']}
lock = threading.Lock()
def _creds_mtime():
try: return max(os.stat(os.path.expanduser(f)).st_mtime for f in ('~/.claude/.credentials.json', '~/.codex/auth.json') if os.path.exists(os.path.expanduser(f)))
except Exception: return 0
def data():
with lock:
due = time.time() - cache['t'] >= cache['wait'] - 1
if cache.get('expired_mtime') is not None and not due and _creds_mtime() != cache['expired_mtime']:
due = True # the sign-in file changed: try now
if due:
cache['expired_mtime'] = None
try:
cache['v'] = data_fn(); cache['wait'] = refresh_ms/1000
if not cache['v'].get('error'):
cache['good'] = cache['v']; cache['fails'] = 0; cache['good_at'] = time.time()
_save_state(title, {'good': cache['good'], 'at': cache['good_at'], 'view': cache['view']})
except Exception as e:
# A 401/403 is almost always an EXPIRED access token, not a missing sign-in:
# Claude Code and Codex refresh their own tokens the next time they run, and
# the widget re-reads the file. Never tell a signed-in user to sign in again
# (AdomLapper 2026-09-14: "sign in to Claude again" while John was chatting).
# Say what is true, keep the last good numbers on screen (dimmed), and retry
# every minute instead of backing off; other errors still back off.
code = getattr(e, 'code', None)
if code in (401, 403):
msg = '%s token expired, waiting for %s to refresh it' % (title.replace(' usage', ''), 'Claude Code' if 'Claude' in title else 'Codex')
# No polling while expired (John 2026-09-14: "stop that loop when it is not
# needed"): the next call happens when the sign-in file changes, or after
# fifteen minutes as a safety net. A stat per page poll is the whole cost.
cache['wait'] = 900; cache['expired_mtime'] = _creds_mtime()
else:
# Plain words, never a raw exception (AdomLapper 2026-09-15 after a reboot:
# "HTTP Error 404: Not Found" sat in the Title Bar for fifteen minutes while
# the endpoint answered 200 to a manual call a minute later).
reason = ('HTTP %s' % code) if code else ('network' if 'URLError' in type(e).__name__ or 'timed out' in str(e) else type(e).__name__)
msg = 'no answer from %s (%s), retrying' % (provider, reason)
cache['fails'] += 1
# Rate-limited: the provider asked us to slow down, so go straight to the top
# of the ladder instead of trying again in 5 s.
cache['wait'] = 900 if code == 429 else [5, 15, 45, 90, 180, 300, 600, 900][min(cache['fails'] - 1, 7)]
cache['v'] = {'error': msg, 'stale': cache['good']} if cache['good'] else {'error': msg}
cache['t'] = time.time()
out = dict(cache['v']); out['view'] = cache['view']
if cache.get('good_at'): out['at'] = int(cache['good_at'])
if out.get('error'): out['retry_in'] = max(0, int(cache['wait'] - (time.time() - cache['t'])))
return out
def refresh_now():
# A click on the widget: make the next data() call due now, whatever the schedule says.
with lock:
cache['t'] = 0; cache['expired_mtime'] = None
return data()
def set_view(v):
with lock:
cache['view'] = v
s = _load_state(title); s['view'] = v; _save_state(title, s)
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def _send(self, code, ctype, body):
self.send_response(code); self.send_header('content-type', ctype); self.send_header('cache-control', 'no-store')
self.send_header('access-control-allow-origin', '*'); self.end_headers(); self.wfile.write(body)
def do_POST(self):
if self.path.startswith('/view'):
n = int(self.headers.get('content-length') or 0)
v = self.rfile.read(n).decode(errors='replace').strip()[:40] if n else ''
if v: set_view(v)
self._send(200, 'application/json', json.dumps({'ok': True, 'view': cache['view']}).encode())
elif self.path.startswith('/refresh'):
self._send(200, 'application/json', json.dumps(refresh_now()).encode())
else: self._send(404, 'text/plain', b'not found')
def do_GET(self):
if self.path.startswith('/data.json'): self._send(200, 'application/json', json.dumps(data()).encode())
elif self.path.startswith('/preview'):
# /preview?h=38 (or ?w=175&h=38): this widget's own page in a frame of that height, offset
# from the corner, so a browser screenshot of the frame (at any device scale) is a
# clean, crisp picture for docs. Without w the frame follows the width the widget asks
# for, exactly as the Title Bar slot does; with w it is pinned. The slot never uses this.
# /preview?x=1: the hover view instead, in a frame that follows the size the page asks
# Hydrogen for ({hdWidget:{expand}}), on the card's ground, so it can be pictured too.
from urllib.parse import urlparse, parse_qs
q = parse_qs(urlparse(self.path).query)
fixed = 'w' in q
expand = q.get('x', ['0'])[0] not in ('0', '')
try: w = int(q.get('w', ['380' if expand else '175'])[0]); h = int(q.get('h', ['300' if expand else '38'])[0])
except ValueError: w, h = 175, 38
w = max(48, min(720, w)); h = max(16, min(600, h))
ground = '#161b22' if expand else '#1c2128'
page = ('<!doctype html><meta charset="utf-8"><title>%s preview</title>'
'<body style="margin:0;background:#0f1419"><div style="padding:%s">'
'<iframe id="w" src="/" data-w="0" style="width:%dpx;height:%dpx;border:1px solid #2c323a;border-radius:6px;background:%s;display:block"></iframe>'
'</div><script>const fixed=%s,expand=%s;window.addEventListener("message",e=>{const m=e.data&&e.data.hdWidget;if(!m)return;'
'const f=document.getElementById("w");'
'if(expand){if(m.expand&&m.expand.width){f.style.width=m.expand.width+"px";f.style.height=Math.max(120,Math.min(600,m.expand.height))+"px";}return;}'
'if(!m.width)return;f.dataset.w=m.width;if(!fixed)f.style.width=Math.max(48,Math.min(640,m.width))+"px";});</script></body>') % (
title, '40px' if expand else '150px 0 40px 40px', w, h, ground, 'true' if fixed else 'false', 'true' if expand else 'false')
self._send(200, 'text/html; charset=utf-8', page.encode())
elif self.path.startswith('/health'): self._send(200, 'application/json', b'{"ok":true}')
else: self._send(200, 'text/html; charset=utf-8', (PAGE % {'title': title, 'default_view': default_view, 'refresh_ms': refresh_ms, 'align_ms': int(align_ms), 'render_js': render_js, 'x_css': X_CSS, 'x_js': X_JS, 'mark_html': ('<span class="mark" style="color:%s"><svg viewBox="%s">%s</svg></span>' % (mark.get('color', 'currentColor'), mark.get('viewBox', '0 0 16 16'), mark.get('svg', ''))) if mark.get('svg') else ''}).encode())
# Bind with retries: a previous instance Hydrogen just stopped may still be releasing the
# port for a moment. Ten tries over five seconds, then give up loudly (the log says why).
ThreadingHTTPServer.allow_reuse_address = True
srv = None
for attempt in range(10):
try:
srv = ThreadingHTTPServer(('127.0.0.1', port), H); break
except OSError as e:
if attempt == 9: raise
time.sleep(0.5)
# Tell Hydrogen we are up (the health poll would find us anyway; this is the explicit handshake).
ctl = os.environ.get('ADOM_HD_CONTROL_URL'); wid = os.environ.get('HYDROGEN_WIDGET_ID')
if ctl and wid:
def announce():
try:
proxy = os.environ.get('HYDROGEN_PROXY_PORT', '')
url = f"http://{port}.localhost:{proxy}/" if proxy else f"http://127.0.0.1:{port}/"
req = urllib.request.Request(f"{ctl}/widgets/navigate", data=json.dumps({'id': wid, 'url': url}).encode(), headers={'content-type': 'application/json'})
urllib.request.urlopen(req, timeout=5)
except Exception: pass
threading.Timer(0.5, announce).start()
srv.serve_forever()
_creds = {}
def read_json_if_changed(path):
"""Parse a JSON file only when its mtime moved; the parsed value is cached otherwise."""
path = os.path.expanduser(path)
st = os.stat(path)
ent = _creds.get(path)
if ent and ent[0] == st.st_mtime_ns: return ent[1]
v = json.load(open(path)); _creds[path] = (st.st_mtime_ns, v); return v
def http_json(url, headers):
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read().decode())